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:
@@ -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,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>
|
||||
+20
-9
@@ -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
|
||||
|
||||
+70
-43
@@ -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
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user