Skip to content

Commit

Permalink
Alessandro/web 2360 request to join workspace (#3799)
Browse files Browse the repository at this point in the history
* feat(workspaces): request to join workspace mutation

* feat(workspaces): random email in test

* feat(workspaces): update email

* feat(workspaces): code review changes

* chore(workspaces): fix tests
  • Loading branch information
alemagio authored Jan 14, 2025
1 parent 8aadfc9 commit 37ede3b
Show file tree
Hide file tree
Showing 9 changed files with 357 additions and 11 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -155,12 +155,18 @@ type WorkspaceMutations {
Dismiss a workspace from the discoverable list, behind the scene a join request is created with the status "dismissed"
"""
dismiss(input: WorkspaceDismissInput!): Boolean! @hasServerRole(role: SERVER_USER)
requestToJoin(input: WorkspaceRequestToJoinInput!): Boolean!
@hasServerRole(role: SERVER_USER)
}

input WorkspaceDismissInput {
workspaceId: ID!
}

input WorkspaceRequestToJoinInput {
workspaceId: ID!
}

input WorkspaceCreationStateInput {
workspaceId: ID!
completed: Boolean!
Expand Down
14 changes: 14 additions & 0 deletions packages/server/modules/core/graph/generated/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4404,11 +4404,13 @@ export type WorkspaceMutations = {
delete: Scalars['Boolean']['output'];
deleteDomain: Workspace;
deleteSsoProvider: Scalars['Boolean']['output'];
/** Dismiss a workspace from the discoverable list, behind the scene a join request is created with the status "dismissed" */
dismiss: Scalars['Boolean']['output'];
invites: WorkspaceInviteMutations;
join: Workspace;
leave: Scalars['Boolean']['output'];
projects: WorkspaceProjectMutations;
requestToJoin: Scalars['Boolean']['output'];
/** Set the default region where project data will be stored. Only available to admins. */
setDefaultRegion: Workspace;
update: Workspace;
Expand Down Expand Up @@ -4457,6 +4459,11 @@ export type WorkspaceMutationsLeaveArgs = {
};


export type WorkspaceMutationsRequestToJoinArgs = {
input: WorkspaceRequestToJoinInput;
};


export type WorkspaceMutationsSetDefaultRegionArgs = {
regionKey: Scalars['String']['input'];
workspaceId: Scalars['String']['input'];
Expand Down Expand Up @@ -4566,6 +4573,10 @@ export enum WorkspaceProjectsUpdatedMessageType {
Removed = 'REMOVED'
}

export type WorkspaceRequestToJoinInput = {
workspaceId: Scalars['ID']['input'];
};

export enum WorkspaceRole {
Admin = 'ADMIN',
Guest = 'GUEST',
Expand Down Expand Up @@ -5001,6 +5012,7 @@ export type ResolversTypes = {
WorkspaceProjectsFilter: WorkspaceProjectsFilter;
WorkspaceProjectsUpdatedMessage: ResolverTypeWrapper<Omit<WorkspaceProjectsUpdatedMessage, 'project'> & { project?: Maybe<ResolversTypes['Project']> }>;
WorkspaceProjectsUpdatedMessageType: WorkspaceProjectsUpdatedMessageType;
WorkspaceRequestToJoinInput: WorkspaceRequestToJoinInput;
WorkspaceRole: WorkspaceRole;
WorkspaceRoleDeleteInput: WorkspaceRoleDeleteInput;
WorkspaceRoleUpdateInput: WorkspaceRoleUpdateInput;
Expand Down Expand Up @@ -5270,6 +5282,7 @@ export type ResolversParentTypes = {
WorkspaceProjectMutations: WorkspaceProjectMutationsGraphQLReturn;
WorkspaceProjectsFilter: WorkspaceProjectsFilter;
WorkspaceProjectsUpdatedMessage: Omit<WorkspaceProjectsUpdatedMessage, 'project'> & { project?: Maybe<ResolversParentTypes['Project']> };
WorkspaceRequestToJoinInput: WorkspaceRequestToJoinInput;
WorkspaceRoleDeleteInput: WorkspaceRoleDeleteInput;
WorkspaceRoleUpdateInput: WorkspaceRoleUpdateInput;
WorkspaceSso: WorkspaceSsoGraphQLReturn;
Expand Down Expand Up @@ -6796,6 +6809,7 @@ export type WorkspaceMutationsResolvers<ContextType = GraphQLContext, ParentType
join?: Resolver<ResolversTypes['Workspace'], ParentType, ContextType, RequireFields<WorkspaceMutationsJoinArgs, 'input'>>;
leave?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<WorkspaceMutationsLeaveArgs, 'id'>>;
projects?: Resolver<ResolversTypes['WorkspaceProjectMutations'], ParentType, ContextType>;
requestToJoin?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<WorkspaceMutationsRequestToJoinArgs, 'input'>>;
setDefaultRegion?: Resolver<ResolversTypes['Workspace'], ParentType, ContextType, RequireFields<WorkspaceMutationsSetDefaultRegionArgs, 'regionKey' | 'workspaceId'>>;
update?: Resolver<ResolversTypes['Workspace'], ParentType, ContextType, RequireFields<WorkspaceMutationsUpdateArgs, 'input'>>;
updateCreationState?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<WorkspaceMutationsUpdateCreationStateArgs, 'input'>>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4385,11 +4385,13 @@ export type WorkspaceMutations = {
delete: Scalars['Boolean']['output'];
deleteDomain: Workspace;
deleteSsoProvider: Scalars['Boolean']['output'];
/** Dismiss a workspace from the discoverable list, behind the scene a join request is created with the status "dismissed" */
dismiss: Scalars['Boolean']['output'];
invites: WorkspaceInviteMutations;
join: Workspace;
leave: Scalars['Boolean']['output'];
projects: WorkspaceProjectMutations;
requestToJoin: Scalars['Boolean']['output'];
/** Set the default region where project data will be stored. Only available to admins. */
setDefaultRegion: Workspace;
update: Workspace;
Expand Down Expand Up @@ -4438,6 +4440,11 @@ export type WorkspaceMutationsLeaveArgs = {
};


export type WorkspaceMutationsRequestToJoinArgs = {
input: WorkspaceRequestToJoinInput;
};


export type WorkspaceMutationsSetDefaultRegionArgs = {
regionKey: Scalars['String']['input'];
workspaceId: Scalars['String']['input'];
Expand Down Expand Up @@ -4547,6 +4554,10 @@ export enum WorkspaceProjectsUpdatedMessageType {
Removed = 'REMOVED'
}

export type WorkspaceRequestToJoinInput = {
workspaceId: Scalars['ID']['input'];
};

export enum WorkspaceRole {
Admin = 'ADMIN',
Guest = 'GUEST',
Expand Down
11 changes: 10 additions & 1 deletion packages/server/modules/workspaces/domain/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,4 +315,13 @@ export type UpdateWorkspaceJoinRequestStatus = (params: {
workspaceId: string
userId: string
status: WorkspaceJoinRequestStatus
}) => Promise<Pick<WorkspaceJoinRequest, 'workspaceId' | 'userId'> | undefined>
}) => Promise<number[]>

export type CreateWorkspaceJoinRequest = (params: {
workspaceJoinRequest: Omit<WorkspaceJoinRequest, 'createdAt' | 'updatedAt'>
}) => Promise<WorkspaceJoinRequest>

export type SendWorkspaceJoinRequestReceivedEmail = (params: {
workspace: Pick<Workspace, 'id' | 'name' | 'slug'>
requester: { id: string; name: string; email: string }
}) => Promise<void>
40 changes: 38 additions & 2 deletions packages/server/modules/workspaces/graph/resolvers/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,8 +203,15 @@ import { Knex } from 'knex'
import { getPaginatedItemsFactory } from '@/modules/shared/services/paginatedItems'
import { InvalidWorkspacePlanStatus } from '@/modules/gatekeeper/errors/billing'
import { BadRequestError } from '@/modules/shared/errors'
import { dismissWorkspaceJoinRequestFactory } from '@/modules/workspaces/services/workspaceJoinRequests'
import { updateWorkspaceJoinRequestStatusFactory } from '@/modules/workspaces/repositories/workspaceJoinRequests'
import {
dismissWorkspaceJoinRequestFactory,
requestToJoinWorkspaceFactory,
sendWorkspaceJoinRequestReceivedEmailFactory
} from '@/modules/workspaces/services/workspaceJoinRequests'
import {
createWorkspaceJoinRequestFactory,
updateWorkspaceJoinRequestStatusFactory
} from '@/modules/workspaces/repositories/workspaceJoinRequests'

const eventBus = getEventBus()
const getServerInfo = getServerInfoFactory({ db })
Expand Down Expand Up @@ -784,6 +791,35 @@ export = FF_WORKSPACES_MODULE_ENABLED
db
})
})({ userId: ctx.userId!, workspaceId: args.input.workspaceId })
},
requestToJoin: async (_parent, args, ctx) => {
const transaction = await db.transaction()
const createWorkspaceJoinRequest = createWorkspaceJoinRequestFactory({
db: transaction
})
const sendWorkspaceJoinRequestReceivedEmail =
sendWorkspaceJoinRequestReceivedEmailFactory({
renderEmail,
sendEmail,
getServerInfo,
getWorkspaceCollaborators: getWorkspaceCollaboratorsFactory({
db: transaction
}),
getUserEmails: findEmailsByUserIdFactory({ db: transaction })
})

return await withTransaction(
requestToJoinWorkspaceFactory({
createWorkspaceJoinRequest,
sendWorkspaceJoinRequestReceivedEmail,
getUserById: getUserFactory({ db: transaction }),
getWorkspace: getWorkspaceFactory({ db: transaction })
})({
userId: ctx.userId!,
workspaceId: args.input.workspaceId
}),
transaction
)
}
},
WorkspaceInviteMutations: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { UpdateWorkspaceJoinRequestStatus } from '@/modules/workspaces/domain/operations'
import {
CreateWorkspaceJoinRequest,
UpdateWorkspaceJoinRequestStatus
} from '@/modules/workspaces/domain/operations'
import { WorkspaceJoinRequest } from '@/modules/workspacesCore/domain/types'
import { WorkspaceJoinRequests } from '@/modules/workspacesCore/helpers/db'
import { Knex } from 'knex'
Expand All @@ -8,14 +11,19 @@ const tables = {
db<WorkspaceJoinRequest>(WorkspaceJoinRequests.name)
}

export const createWorkspaceJoinRequestFactory =
({ db }: { db: Knex }): CreateWorkspaceJoinRequest =>
async ({ workspaceJoinRequest }) => {
const res = await tables.workspaceJoinRequests(db).insert(workspaceJoinRequest, '*')
return res[0]
}

export const updateWorkspaceJoinRequestStatusFactory =
({ db }: { db: Knex }): UpdateWorkspaceJoinRequestStatus =>
async ({ workspaceId, userId, status }) => {
const [request] = await tables
return await tables
.workspaceJoinRequests(db)
.insert({ workspaceId, userId, status })
.onConflict(['workspaceId', 'userId'])
.merge(['status'])
.returning('*')
return request
}
154 changes: 153 additions & 1 deletion packages/server/modules/workspaces/services/workspaceJoinRequests.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace'
import { GetServerInfo } from '@/modules/core/domain/server/operations'
import { FindEmailsByUserId } from '@/modules/core/domain/userEmails/operations'
import { GetUser } from '@/modules/core/domain/users/operations'
import { RenderEmail, SendEmail } from '@/modules/emails/domain/operations'
import { NotFoundError } from '@/modules/shared/errors'
import { getFrontendOrigin } from '@/modules/shared/helpers/envHelper'
import {
CreateWorkspaceJoinRequest,
GetWorkspace,
GetWorkspaceCollaborators,
SendWorkspaceJoinRequestReceivedEmail,
UpdateWorkspaceJoinRequestStatus
} from '@/modules/workspaces/domain/operations'
import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace'
import { Roles } from '@speckle/shared'

export const dismissWorkspaceJoinRequestFactory =
({
Expand All @@ -24,3 +34,145 @@ export const dismissWorkspaceJoinRequestFactory =
})
return true
}

type WorkspaceJoinRequestReceivedEmailArgs = {
workspace: { id: string; name: string; slug: string }
requester: { name: string }
workspaceAdmin: { id: string; name: string }
}

const buildMjmlBody = ({
workspace,
requester,
workspaceAdmin
}: WorkspaceJoinRequestReceivedEmailArgs) => {
const bodyStart = `<mj-text>
Hi ${workspaceAdmin.name}!
<br/>
<br/>
<span style="font-weight: bold;">${requester.name}</span> is requesting to join your workspace <span style="font-weight: bold;">${workspace.name}</span>.
<br/>
<br/>
</mj-text>
`
const bodyEnd = `<mj-text>
<span style="font-weight: bold;">Have questions or feedback?</span> Please write us at <a href="mailto:[email protected]" target="_blank">[email protected]</a> and we'd be more than happy to talk.
</mj-text>`
return { bodyStart, bodyEnd }
}

const buildTextBody = ({
workspace,
requester,
workspaceAdmin
}: WorkspaceJoinRequestReceivedEmailArgs) => {
const bodyStart = `
Hi ${workspaceAdmin.name}!
\r\n\r\n
${requester.name} is requesting to join your workspace ${workspace.name}.
\r\n\r\n
`
const bodyEnd = `Have questions or feedback? Please write us at [email protected] and we'd be more than happy to talk.`
return { bodyStart, bodyEnd }
}

const buildEmailTemplateParams = (args: WorkspaceJoinRequestReceivedEmailArgs) => {
const url = new URL(
`workspaces/${args.workspace.slug}`,
getFrontendOrigin()
).toString()
return {
mjml: buildMjmlBody(args),
text: buildTextBody(args),
cta: {
title: 'Manage Members',
url
}
}
}

export const sendWorkspaceJoinRequestReceivedEmailFactory =
({
renderEmail,
sendEmail,
getServerInfo,
getWorkspaceCollaborators,
getUserEmails
}: {
renderEmail: RenderEmail
sendEmail: SendEmail
getServerInfo: GetServerInfo
getWorkspaceCollaborators: GetWorkspaceCollaborators
getUserEmails: FindEmailsByUserId
}) =>
async (args: Omit<WorkspaceJoinRequestReceivedEmailArgs, 'workspaceAdmin'>) => {
const { requester, workspace } = args
const [serverInfo, workspaceAdmins] = await Promise.all([
getServerInfo(),
getWorkspaceCollaborators({
workspaceId: workspace.id,
limit: 100,
filter: { roles: [Roles.Workspace.Admin] }
})
])
const sendEmailParams = await Promise.all(
workspaceAdmins.map(async (admin) => {
const userEmails = await getUserEmails({ userId: admin.id })
const emailTemplateParams = buildEmailTemplateParams({
requester,
workspace,
workspaceAdmin: admin
})
const { html, text } = await renderEmail(emailTemplateParams, serverInfo, null)
const subject = `${requester.name} wants to join your workspace`
const sendEmailParams = {
html,
text,
subject,
to: userEmails.map((e) => e.email)
}
return sendEmailParams
})
)
await Promise.all(sendEmailParams.map((params) => sendEmail(params)))
}

export const requestToJoinWorkspaceFactory =
({
createWorkspaceJoinRequest,
sendWorkspaceJoinRequestReceivedEmail,
getUserById,
getWorkspace
}: {
createWorkspaceJoinRequest: CreateWorkspaceJoinRequest
sendWorkspaceJoinRequestReceivedEmail: SendWorkspaceJoinRequestReceivedEmail
getUserById: GetUser
getWorkspace: GetWorkspace
}) =>
async ({ userId, workspaceId }: { userId: string; workspaceId: string }) => {
await createWorkspaceJoinRequest({
workspaceJoinRequest: {
userId,
workspaceId,
status: 'pending'
}
})

const requester = await getUserById(userId)
if (!requester) {
throw new NotFoundError('User not found')
}

const workspace = await getWorkspace({ workspaceId })
if (!workspace) {
throw new NotFoundError('Workspace not found')
}

await sendWorkspaceJoinRequestReceivedEmail({
workspace,
requester
})

return true
}
Loading

0 comments on commit 37ede3b

Please sign in to comment.