diff --git a/packages/server/modules/emails/domain/operations.ts b/packages/server/modules/emails/domain/operations.ts index 14eea938c5..64b95d3796 100644 --- a/packages/server/modules/emails/domain/operations.ts +++ b/packages/server/modules/emails/domain/operations.ts @@ -1,3 +1,4 @@ +import { UserRecord } from '@/modules/core/helpers/types' import { EmailVerificationRecord } from '@/modules/emails/repositories' /** @@ -20,3 +21,58 @@ export type DeleteOldAndInsertNewVerification = (email: string) => Promise Promise export type RequestEmailVerification = (userId: string) => Promise + +export type SendEmailParams = { + from?: string + to: string | string[] + subject: string + text: string + html: string +} +export type SendEmail = (args: SendEmailParams) => Promise + +export type EmailTemplateServerInfo = { + name: string + canonicalUrl: string + company: string + adminContact: string +} + +export type EmailCta = { + title: string + url: string +} + +export type EmailBody = { + text: string + mjml: string +} + +export type EmailInput = { + from?: string + to: string + subject: string + text: string + html: string +} + +export type EmailContent = { + text: string + html: string +} + +export type EmailTemplateParams = { + mjml: { bodyStart: string; bodyEnd?: string } + text: { bodyStart: string; bodyEnd?: string } + cta?: { + url: string + title: string + altTitle?: string + } +} + +export type RenderEmail = ( + templateParams: EmailTemplateParams, + serverInfo: EmailTemplateServerInfo, + user: UserRecord | null +) => Promise diff --git a/packages/server/modules/emails/services/emailRendering.ts b/packages/server/modules/emails/services/emailRendering.ts index 784f74a373..613f8fbd2e 100644 --- a/packages/server/modules/emails/services/emailRendering.ts +++ b/packages/server/modules/emails/services/emailRendering.ts @@ -4,46 +4,11 @@ import path from 'path' import mjml2html from 'mjml' import * as ejs from 'ejs' import sanitizeHtml from 'sanitize-html' - -export type EmailTemplateServerInfo = { - name: string - canonicalUrl: string - company: string - adminContact: string -} - -export type EmailCta = { - title: string - url: string -} - -export type EmailBody = { - text: string - mjml: string -} - -export type EmailTemplateParams = { - mjml: { bodyStart: string; bodyEnd?: string } - text: { bodyStart: string; bodyEnd?: string } - cta?: { - url: string - title: string - altTitle?: string - } -} - -export type EmailInput = { - from?: string - to: string - subject: string - text: string - html: string -} - -export type EmailContent = { - text: string - html: string -} +import { + EmailContent, + EmailTemplateParams, + EmailTemplateServerInfo +} from '@/modules/emails/domain/operations' export const renderEmail = async ( templateParams: EmailTemplateParams, diff --git a/packages/server/modules/emails/services/sending.ts b/packages/server/modules/emails/services/sending.ts index 232276b5b1..733b2b43ab 100644 --- a/packages/server/modules/emails/services/sending.ts +++ b/packages/server/modules/emails/services/sending.ts @@ -1,26 +1,20 @@ import { logger } from '@/logging/logging' +import { SendEmail, SendEmailParams } from '@/modules/emails/domain/operations' import { getTransporter } from '@/modules/emails/utils/transporter' import { getEmailFromAddress } from '@/modules/shared/helpers/envHelper' import { resolveMixpanelUserId } from '@speckle/shared' -export type SendEmailParams = { - from?: string - to: string - subject: string - text: string - html: string -} - +export type { SendEmailParams } from '@/modules/emails/domain/operations' /** * Send out an e-mail */ -export async function sendEmail({ +export const sendEmail: SendEmail = async ({ from, to, subject, text, html -}: SendEmailParams): Promise { +}: SendEmailParams): Promise => { const transporter = getTransporter() if (!transporter) { logger.warn('No email transport present. Cannot send emails. Skipping send...') @@ -35,12 +29,16 @@ export async function sendEmail({ text, html }) + const emails = typeof to === 'string' ? [to] : to + const distinctIds = await Promise.all( + emails.map((email) => resolveMixpanelUserId(email)) + ) logger.info( { subject, - distinctId: resolveMixpanelUserId(to || '') + distinctIds }, - 'Email "{subject}" sent out to distinctId {distinctId}' + 'Email "{subject}" sent out to distinctIds {distinctIds}' ) return true } catch (error) { diff --git a/packages/server/modules/emails/services/verification/request.ts b/packages/server/modules/emails/services/verification/request.ts index 38ae625e2b..3c6f3a9d88 100644 --- a/packages/server/modules/emails/services/verification/request.ts +++ b/packages/server/modules/emails/services/verification/request.ts @@ -6,16 +6,14 @@ import { UserEmail } from '@/modules/core/domain/userEmails/types' import { getEmailVerificationFinalizationRoute } from '@/modules/core/helpers/routeHelper' import { ServerInfo, UserRecord } from '@/modules/core/helpers/types' import { EmailVerificationRequestError } from '@/modules/emails/errors' -import { - EmailTemplateParams, - renderEmail -} from '@/modules/emails/services/emailRendering' -import { sendEmail } from '@/modules/emails/services/sending' import { getServerOrigin } from '@/modules/shared/helpers/envHelper' import { DeleteOldAndInsertNewVerification, + EmailTemplateParams, + RenderEmail, RequestEmailVerification, - RequestNewEmailVerification + RequestNewEmailVerification, + SendEmail } from '@/modules/emails/domain/operations' import { GetUser } from '@/modules/core/domain/users/operations' import { GetServerInfo } from '@/modules/core/domain/server/operations' @@ -150,8 +148,8 @@ function buildEmailTemplateParams(verificationId: string): EmailTemplateParams { } type SendVerificationEmailDeps = { - sendEmail: typeof sendEmail - renderEmail: typeof renderEmail + sendEmail: SendEmail + renderEmail: RenderEmail } const sendVerificationEmailFactory = diff --git a/packages/server/modules/emails/tests/emailTemplating.spec.ts b/packages/server/modules/emails/tests/emailTemplating.spec.ts index 4fde204f76..075c45d70e 100644 --- a/packages/server/modules/emails/tests/emailTemplating.spec.ts +++ b/packages/server/modules/emails/tests/emailTemplating.spec.ts @@ -1,9 +1,7 @@ import { db } from '@/db/knex' import { getServerInfoFactory } from '@/modules/core/repositories/server' -import { - EmailTemplateServerInfo, - renderEmail -} from '@/modules/emails/services/emailRendering' +import { EmailTemplateServerInfo } from '@/modules/emails/domain/operations' +import { renderEmail } from '@/modules/emails/services/emailRendering' import { expect } from 'chai' import sanitize from 'sanitize-html' diff --git a/packages/server/modules/gatekeeper/domain/operations.ts b/packages/server/modules/gatekeeper/domain/operations.ts index a761a60966..6d19a84a4c 100644 --- a/packages/server/modules/gatekeeper/domain/operations.ts +++ b/packages/server/modules/gatekeeper/domain/operations.ts @@ -1,5 +1,9 @@ -import { WorkspacePlan } from '@/modules/gatekeeper/domain/billing' -import { WorkspaceFeatureName } from '@/modules/gatekeeper/domain/workspacePricing' +import { PlanStatuses, WorkspacePlan } from '@/modules/gatekeeper/domain/billing' +import { + WorkspaceFeatureName, + WorkspacePlans +} from '@/modules/gatekeeper/domain/workspacePricing' +import { Workspace } from '@/modules/workspacesCore/domain/types' export type CanWorkspaceAccessFeature = (args: { workspaceId: string @@ -14,6 +18,12 @@ export type ChangeExpiredTrialWorkspacePlanStatuses = (args: { numberOfDays: number }) => Promise +export type GetWorkspacesByPlanDaysTillExpiry = (args: { + daysTillExpiry: number + planValidFor: number + plan: WorkspacePlans + status: PlanStatuses +}) => Promise export type GetWorkspacePlanByProjectId = ({ projectId }: { diff --git a/packages/server/modules/gatekeeper/index.ts b/packages/server/modules/gatekeeper/index.ts index 71185bb394..49e2298d02 100644 --- a/packages/server/modules/gatekeeper/index.ts +++ b/packages/server/modules/gatekeeper/index.ts @@ -22,13 +22,22 @@ import { changeExpiredTrialWorkspacePlanStatusesFactory, getWorkspacePlanByProjectIdFactory, getWorkspacePlanFactory, + getWorkspacesByPlanAgeFactory, getWorkspaceSubscriptionsPastBillingCycleEndFactory, upsertWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/repositories/billing' -import { countWorkspaceRoleWithOptionalProjectRoleFactory } from '@/modules/workspaces/repositories/workspaces' +import { + countWorkspaceRoleWithOptionalProjectRoleFactory, + getWorkspaceCollaboratorsFactory +} from '@/modules/workspaces/repositories/workspaces' import { reconcileWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/clients/stripe' import { ScheduleExecution } from '@/modules/core/domain/scheduledTasks/operations' import { EventBusEmit, getEventBus } from '@/modules/shared/services/eventBus' +import { sendWorkspaceTrialExpiresEmailFactory } from '@/modules/gatekeeper/services/trialEmails' +import { getServerInfoFactory } from '@/modules/core/repositories/server' +import { findEmailsByUserIdFactory } from '@/modules/core/repositories/userEmails' +import { sendEmail } from '@/modules/emails/services/sending' +import { renderEmail } from '@/modules/emails/services/emailRendering' import coreModule from '@/modules/core/index' import { isProjectReadOnlyFactory } from '@/modules/gatekeeper/services/readOnly' import { WorkspaceReadOnlyError } from '@/modules/gatekeeper/errors/billing' @@ -77,10 +86,46 @@ const scheduleWorkspaceTrialEmails = ({ }: { scheduleExecution: ScheduleExecution }) => { + const sendWorkspaceTrialEmail = sendWorkspaceTrialExpiresEmailFactory({ + getServerInfo: getServerInfoFactory({ db }), + getUserEmails: findEmailsByUserIdFactory({ db }), + getWorkspaceCollaborators: getWorkspaceCollaboratorsFactory({ db }), + sendEmail, + renderEmail + }) // TODO: make this a daily thing - const cronExpression = '*/5 * * * *' + // const cronExpression = '*/5 * * * * *' + // every day at noon + const cronExpression = '0 12 * * *' return scheduleExecution(cronExpression, 'WorkspaceTrialEmails', async () => { - // await manageSubscriptionDownscale() + const getWorkspacesByPlanAge = getWorkspacesByPlanAgeFactory({ db }) + const trialValidForDays = 31 + const trialWorkspacesExpireIn3Days = await getWorkspacesByPlanAge({ + daysTillExpiry: 3, + planValidFor: trialValidForDays, + plan: 'starter', + status: 'trial' + }) + if (trialWorkspacesExpireIn3Days.length) { + await Promise.all( + trialWorkspacesExpireIn3Days.map((workspace) => + sendWorkspaceTrialEmail({ workspace, expiresInDays: 3 }) + ) + ) + } + const trialWorkspacesExpireToday = await getWorkspacesByPlanAge({ + daysTillExpiry: 0, + planValidFor: trialValidForDays, + plan: 'starter', + status: 'trial' + }) + if (trialWorkspacesExpireToday.length) { + await Promise.all( + trialWorkspacesExpireToday.map((workspace) => + sendWorkspaceTrialEmail({ workspace, expiresInDays: 0 }) + ) + ) + } }) } diff --git a/packages/server/modules/gatekeeper/repositories/billing.ts b/packages/server/modules/gatekeeper/repositories/billing.ts index c71cda8a59..ec76b42cc6 100644 --- a/packages/server/modules/gatekeeper/repositories/billing.ts +++ b/packages/server/modules/gatekeeper/repositories/billing.ts @@ -20,12 +20,15 @@ import { } from '@/modules/gatekeeper/domain/billing' import { ChangeExpiredTrialWorkspacePlanStatuses, + GetWorkspacesByPlanDaysTillExpiry, GetWorkspacePlanByProjectId } from '@/modules/gatekeeper/domain/operations' +import { Workspace } from '@/modules/workspacesCore/domain/types' import { Workspaces } from '@/modules/workspacesCore/helpers/db' import { Knex } from 'knex' const tables = { + workspaces: (db: Knex) => db('workspaces'), workspacePlans: (db: Knex) => db('workspace_plans'), workspaceCheckoutSessions: (db: Knex) => db('workspace_checkout_sessions'), @@ -85,6 +88,21 @@ export const changeExpiredTrialWorkspacePlanStatusesFactory = .returning('*') } +export const getWorkspacesByPlanAgeFactory = + ({ db }: { db: Knex }): GetWorkspacesByPlanDaysTillExpiry => + async ({ daysTillExpiry, planValidFor, plan, status }) => { + return await tables + .workspaces(db) + .select('workspaces.*') + .join('workspace_plans', 'workspaces.id', 'workspace_plans.workspaceId') + .where('workspace_plans.status', status) + .andWhere('workspace_plans.name', plan) + .andWhereRaw('? - extract(day from now () - workspace_plans."createdAt") = ?', [ + planValidFor, + daysTillExpiry + ]) + } + export const saveCheckoutSessionFactory = ({ db }: { db: Knex }): SaveCheckoutSession => async ({ checkoutSession }) => { diff --git a/packages/server/modules/gatekeeper/services/trialEmails.ts b/packages/server/modules/gatekeeper/services/trialEmails.ts new file mode 100644 index 0000000000..d5226a01a5 --- /dev/null +++ b/packages/server/modules/gatekeeper/services/trialEmails.ts @@ -0,0 +1,130 @@ +import { GetServerInfo } from '@/modules/core/domain/server/operations' +import { FindEmailsByUserId } from '@/modules/core/domain/userEmails/operations' +import { + EmailTemplateParams, + RenderEmail, + SendEmail, + SendEmailParams +} from '@/modules/emails/domain/operations' +import { getServerOrigin } from '@/modules/shared/helpers/envHelper' +import { GetWorkspaceCollaborators } from '@/modules/workspaces/domain/operations' +import { WorkspaceTeamMember } from '@/modules/workspaces/domain/types' +import { Workspace } from '@/modules/workspacesCore/domain/types' +import { Roles } from '@speckle/shared' + +type TrialExpiresArgs = { + workspace: Workspace + expiresInDays: number +} + +type TrialExpiresArgsWithAdmin = TrialExpiresArgs & { + workspaceAdmin: WorkspaceTeamMember +} + +const buildMjmlBody = ({ + workspace, + expiresInDays, + workspaceAdmin +}: TrialExpiresArgsWithAdmin) => { + const expireMessage = + expiresInDays === 0 + ? `today` + : `in ${expiresInDays} days` + const bodyStart = ` +Hi ${workspaceAdmin.name}! +
+
+The trial for your workspace ${workspace.name} expires ${expireMessage}. +
+
+Upgrade to a paid plan before the trial expires to keep using your workspace. You can compare plans and get an overview of your estimated billing from your workspace's billing settings. +
+
+ +
+ ` + const bodyEnd = ` +Have questions or feedback? Please write us at hello@speckle.systems and we'd be more than happy to talk. + ` + return { bodyStart, bodyEnd } +} + +const buildTextBody = ({ + workspace, + expiresInDays, + workspaceAdmin +}: TrialExpiresArgsWithAdmin) => { + const expireMessage = expiresInDays === 0 ? `today` : `in ${expiresInDays} days` + const bodyStart = ` + +Hi ${workspaceAdmin.name}! +\r\n\r\n +The trial for your workspace ${workspace.name} expires ${expireMessage}. +\r\n\r\n +Upgrade to a paid plan before the trial expires to keep using your workspace. You can compare plans and get an overview of your estimated billing from your workspace's billing settings. +\r\n\r\n + ` + const bodyEnd = `Have questions or feedback? Please write us at hello@speckle.systems and we'd be more than happy to talk.` + return { bodyStart, bodyEnd } +} + +const buildEmailTemplateParams = ( + args: TrialExpiresArgsWithAdmin +): EmailTemplateParams => { + const url = new URL(`workspaces/${args.workspace.slug}`, getServerOrigin()).toString() + return { + mjml: buildMjmlBody(args), + text: buildTextBody(args), + cta: { + title: 'Upgrade your workspace', + url + } + } +} + +export const sendWorkspaceTrialExpiresEmailFactory = + ({ + renderEmail, + sendEmail, + getServerInfo, + getWorkspaceCollaborators, + getUserEmails + }: { + renderEmail: RenderEmail + sendEmail: SendEmail + getServerInfo: GetServerInfo + getWorkspaceCollaborators: GetWorkspaceCollaborators + getUserEmails: FindEmailsByUserId + }) => + async (args: TrialExpiresArgs) => { + const [serverInfo, workspaceAdmins] = await Promise.all([ + getServerInfo(), + getWorkspaceCollaborators({ + workspaceId: args.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({ + ...args, + workspaceAdmin: admin + }) + const { html, text } = await renderEmail(emailTemplateParams, serverInfo, null) + const subject = + args.expiresInDays === 0 + ? 'Your workspace trial expires today' + : `Your workspace trial expires in ${args.expiresInDays} days` + const sendEmailParams: SendEmailParams = { + html, + text, + subject, + to: userEmails.map((e) => e.email) + } + return sendEmailParams + }) + ) + await Promise.all(sendEmailParams.map((params) => sendEmail(params))) + } diff --git a/packages/server/modules/gatekeeper/tests/intergration/billingRepositories.spec.ts b/packages/server/modules/gatekeeper/tests/intergration/billingRepositories.spec.ts index 8b0cf6403e..10ed8c67e4 100644 --- a/packages/server/modules/gatekeeper/tests/intergration/billingRepositories.spec.ts +++ b/packages/server/modules/gatekeeper/tests/intergration/billingRepositories.spec.ts @@ -12,7 +12,8 @@ import { getWorkspaceSubscriptionBySubscriptionIdFactory, getWorkspaceSubscriptionsPastBillingCycleEndFactory, changeExpiredTrialWorkspacePlanStatusesFactory, - upsertTrialWorkspacePlanFactory + upsertTrialWorkspacePlanFactory, + getWorkspacesByPlanAgeFactory } from '@/modules/gatekeeper/repositories/billing' import { createTestSubscriptionData, @@ -23,6 +24,7 @@ import { truncateTables } from '@/test/hooks' import { createAndStoreTestWorkspaceFactory } from '@/test/speckle-helpers/workspaces' import { expect } from 'chai' import cryptoRandomString from 'crypto-random-string' +import { beforeEach } from 'mocha' const upsertWorkspace = upsertWorkspaceFactory({ db }) const createAndStoreTestWorkspace = createAndStoreTestWorkspaceFactory({ @@ -46,9 +48,13 @@ const getSubscriptionsAboutToEndBillingCycle = const changeExpiredTrialWorkspacePlanStatuses = changeExpiredTrialWorkspacePlanStatusesFactory({ db }) +const getWorkspacesByPlanAge = getWorkspacesByPlanAgeFactory({ db }) describe('billing repositories @gatekeeper', () => { describe('workspacePlans', () => { + beforeEach(async () => { + await truncateTables(['workspace_plans']) + }) describe('upsertPaidWorkspacePlanFactory creates a function, that', () => { it('creates a workspacePlan if it does not exist', async () => { const workspace = await createAndStoreTestWorkspace() @@ -175,6 +181,147 @@ describe('billing repositories @gatekeeper', () => { expect(storedWorkspacePlan).deep.equal(workspace3Plan) }) }) + describe('getWorkspaceByPlanAgeFactory returns a function, that', () => { + it('gets workspace where days to expire matches expected', async () => { + const workspace1 = await createAndStoreTestWorkspace() + const createdAt1 = new Date() + createdAt1.setHours(createdAt1.getHours() - 22) + const workspace1Plan = { + name: 'business', + status: 'paymentFailed', + createdAt: createdAt1, + workspaceId: workspace1.id + } as const + await upsertPaidWorkspacePlan({ + workspacePlan: workspace1Plan + }) + const workspace2 = await createAndStoreTestWorkspace() + const createdAt2 = new Date() + createdAt2.setHours(createdAt2.getHours() - 2) + const workspacePlan = { + name: 'business', + status: 'paymentFailed', + createdAt: createdAt2, + workspaceId: workspace2.id + } as const + await upsertPaidWorkspacePlan({ + workspacePlan + }) + + const workspacesByPlanByDaysTillExpire = await getWorkspacesByPlanAge({ + planValidFor: 2, + daysTillExpiry: 2, + status: workspacePlan.status, + plan: workspacePlan.name + }) + expect(workspacesByPlanByDaysTillExpire).to.deep.equalInAnyOrder([ + workspace1, + workspace2 + ]) + }) + it('ignores workspaces where plans do not match', async () => { + const workspace1 = await createAndStoreTestWorkspace() + const createdAt1 = new Date() + createdAt1.setHours(createdAt1.getHours() - 22) + const workspace1Plan = { + name: 'business', + status: 'paymentFailed', + createdAt: createdAt1, + workspaceId: workspace1.id + } as const + await upsertPaidWorkspacePlan({ + workspacePlan: workspace1Plan + }) + const workspace2 = await createAndStoreTestWorkspace() + const createdAt2 = new Date() + createdAt2.setHours(createdAt2.getHours() - 2) + const workspace2Plan = { + name: 'starter', + status: 'paymentFailed', + createdAt: createdAt2, + workspaceId: workspace2.id + } as const + await upsertPaidWorkspacePlan({ + workspacePlan: workspace2Plan + }) + + const workspacesByPlanByDaysTillExpire = await getWorkspacesByPlanAge({ + planValidFor: 2, + daysTillExpiry: 2, + status: workspace2Plan.status, + plan: workspace2Plan.name + }) + expect(workspacesByPlanByDaysTillExpire).to.deep.equalInAnyOrder([workspace2]) + }) + it('ignores workspaces where plan statuses do not match', async () => { + const workspace1 = await createAndStoreTestWorkspace() + const createdAt1 = new Date() + createdAt1.setHours(createdAt1.getHours() - 22) + const workspace1Plan = { + name: 'business', + status: 'paymentFailed', + createdAt: createdAt1, + workspaceId: workspace1.id + } as const + await upsertPaidWorkspacePlan({ + workspacePlan: workspace1Plan + }) + const workspace2 = await createAndStoreTestWorkspace() + const createdAt2 = new Date() + createdAt2.setHours(createdAt2.getHours() - 2) + const workspace2Plan = { + name: 'business', + status: 'valid', + createdAt: createdAt2, + workspaceId: workspace2.id + } as const + await upsertPaidWorkspacePlan({ + workspacePlan: workspace2Plan + }) + + const workspacesByPlanByDaysTillExpire = await getWorkspacesByPlanAge({ + planValidFor: 2, + daysTillExpiry: 2, + status: workspace2Plan.status, + plan: workspace2Plan.name + }) + expect(workspacesByPlanByDaysTillExpire).to.deep.equalInAnyOrder([workspace2]) + }) + it('ignores workspaces where plan days till expiry do not match', async () => { + const workspace1 = await createAndStoreTestWorkspace() + const createdAt1 = new Date() + createdAt1.setHours(createdAt1.getHours() - 25) + const workspace1Plan = { + name: 'starter', + status: 'valid', + createdAt: createdAt1, + workspaceId: workspace1.id + } as const + await upsertPaidWorkspacePlan({ + workspacePlan: workspace1Plan + }) + const workspace2 = await createAndStoreTestWorkspace() + const createdAt2 = new Date() + createdAt2.setHours(createdAt2.getHours() - 2) + const workspacePlan2 = { + name: 'starter', + status: 'valid', + createdAt: createdAt2, + workspaceId: workspace2.id + } as const + await upsertPaidWorkspacePlan({ + workspacePlan: workspacePlan2 + }) + + const workspacesByPlanByDaysTillExpire = await getWorkspacesByPlanAge({ + planValidFor: 2, + daysTillExpiry: 2, + status: workspacePlan2.status, + plan: workspacePlan2.name + }) + expect(workspacesByPlanByDaysTillExpire).to.deep.equalInAnyOrder([workspace2]) + }) + }) }) describe('checkoutSessions', () => { describe('saveCheckoutSessionFactory creates a function that,', () => { diff --git a/packages/server/modules/notifications/services/handlers/activityDigest.ts b/packages/server/modules/notifications/services/handlers/activityDigest.ts index 8dc19ccb02..aed78b41b5 100644 --- a/packages/server/modules/notifications/services/handlers/activityDigest.ts +++ b/packages/server/modules/notifications/services/handlers/activityDigest.ts @@ -14,11 +14,7 @@ import { groupBy } from 'lodash' import { packageRoot } from '@/bootstrap' import path from 'path' import * as ejs from 'ejs' -import { - EmailBody, - EmailInput, - renderEmail -} from '@/modules/emails/services/emailRendering' +import { renderEmail } from '@/modules/emails/services/emailRendering' import { getUserNotificationPreferencesFactory } from '@/modules/notifications/services/notificationPreferences' import { getSavedUserNotificationPreferencesFactory } from '@/modules/notifications/repositories' import { db } from '@/db/knex' @@ -34,6 +30,7 @@ import { getStreamFactory } from '@/modules/core/repositories/streams' import { getUserFactory } from '@/modules/core/repositories/users' import { GetServerInfo } from '@/modules/core/domain/server/operations' import { getServerInfoFactory } from '@/modules/core/repositories/server' +import { EmailBody, EmailInput } from '@/modules/emails/domain/operations' const digestNotificationEmailHandlerFactory = ( diff --git a/packages/server/modules/notifications/services/handlers/mentionedInComment.ts b/packages/server/modules/notifications/services/handlers/mentionedInComment.ts index d5d1ca9359..ca411c45ac 100644 --- a/packages/server/modules/notifications/services/handlers/mentionedInComment.ts +++ b/packages/server/modules/notifications/services/handlers/mentionedInComment.ts @@ -12,10 +12,8 @@ import { ServerInfo } from '@/modules/core/helpers/types' import { getServerInfoFactory } from '@/modules/core/repositories/server' import { getStreamFactory } from '@/modules/core/repositories/streams' import { getUserFactory, UserWithOptionalRole } from '@/modules/core/repositories/users' -import { - EmailTemplateParams, - renderEmail -} from '@/modules/emails/services/emailRendering' +import { EmailTemplateParams } from '@/modules/emails/domain/operations' +import { renderEmail } from '@/modules/emails/services/emailRendering' import { sendEmail } from '@/modules/emails/services/sending' import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector' import { NotificationValidationError } from '@/modules/notifications/errors' diff --git a/packages/server/modules/notifications/services/handlers/newStreamAccessRequest.ts b/packages/server/modules/notifications/services/handlers/newStreamAccessRequest.ts index 6209eced09..d2d5751f5d 100644 --- a/packages/server/modules/notifications/services/handlers/newStreamAccessRequest.ts +++ b/packages/server/modules/notifications/services/handlers/newStreamAccessRequest.ts @@ -13,10 +13,7 @@ import { getStreamCollaboratorsRoute } from '@/modules/core/helpers/routeHelper' import { sendEmail } from '@/modules/emails/services/sending' -import { - EmailTemplateParams, - renderEmail -} from '@/modules/emails/services/emailRendering' +import { renderEmail } from '@/modules/emails/services/emailRendering' import { db } from '@/db/knex' import { GetPendingAccessRequest } from '@/modules/accessrequests/domain/operations' import { GetStream } from '@/modules/core/domain/streams/operations' @@ -25,6 +22,7 @@ import { GetUser } from '@/modules/core/domain/users/operations' import { getUserFactory } from '@/modules/core/repositories/users' import { GetServerInfo } from '@/modules/core/domain/server/operations' import { getServerInfoFactory } from '@/modules/core/repositories/server' +import { EmailTemplateParams } from '@/modules/emails/domain/operations' type ValidateMessageDeps = { getPendingAccessRequest: GetPendingAccessRequest diff --git a/packages/server/modules/notifications/services/handlers/streamAccessRequestApproved.ts b/packages/server/modules/notifications/services/handlers/streamAccessRequestApproved.ts index 3e3688e08d..28e7e803be 100644 --- a/packages/server/modules/notifications/services/handlers/streamAccessRequestApproved.ts +++ b/packages/server/modules/notifications/services/handlers/streamAccessRequestApproved.ts @@ -9,10 +9,8 @@ import { import { getServerInfoFactory } from '@/modules/core/repositories/server' import { getStreamFactory } from '@/modules/core/repositories/streams' import { getUserFactory } from '@/modules/core/repositories/users' -import { - EmailTemplateParams, - renderEmail -} from '@/modules/emails/services/emailRendering' +import { EmailTemplateParams } from '@/modules/emails/domain/operations' +import { renderEmail } from '@/modules/emails/services/emailRendering' import { sendEmail } from '@/modules/emails/services/sending' import { NotificationValidationError } from '@/modules/notifications/errors' import { diff --git a/packages/server/modules/pwdreset/services/request.ts b/packages/server/modules/pwdreset/services/request.ts index f08492bb85..f584c99b0a 100644 --- a/packages/server/modules/pwdreset/services/request.ts +++ b/packages/server/modules/pwdreset/services/request.ts @@ -1,10 +1,8 @@ import { GetServerInfo } from '@/modules/core/domain/server/operations' import { GetUserByEmail } from '@/modules/core/domain/users/operations' import { getPasswordResetFinalizationRoute } from '@/modules/core/helpers/routeHelper' -import { - EmailTemplateParams, - renderEmail -} from '@/modules/emails/services/emailRendering' +import { EmailTemplateParams } from '@/modules/emails/domain/operations' +import { renderEmail } from '@/modules/emails/services/emailRendering' import { sendEmail } from '@/modules/emails/services/sending' import { CreateToken, GetPendingToken } from '@/modules/pwdreset/domain/operations' import { InvalidPasswordRecoveryRequestError } from '@/modules/pwdreset/errors' diff --git a/packages/server/modules/serverinvites/services/coreEmailContents.ts b/packages/server/modules/serverinvites/services/coreEmailContents.ts index c33a0beea2..e259dfb476 100644 --- a/packages/server/modules/serverinvites/services/coreEmailContents.ts +++ b/packages/server/modules/serverinvites/services/coreEmailContents.ts @@ -4,10 +4,8 @@ import { getStreamRoute } from '@/modules/core/helpers/routeHelper' import { StreamRecord } from '@/modules/core/helpers/types' -import { - EmailTemplateParams, - sanitizeMessage -} from '@/modules/emails/services/emailRendering' +import { EmailTemplateParams } from '@/modules/emails/domain/operations' +import { sanitizeMessage } from '@/modules/emails/services/emailRendering' import { PrimaryInviteResourceTarget, ProjectInviteResourceTarget diff --git a/packages/server/modules/serverinvites/services/operations.ts b/packages/server/modules/serverinvites/services/operations.ts index 9e127ee519..214028a216 100644 --- a/packages/server/modules/serverinvites/services/operations.ts +++ b/packages/server/modules/serverinvites/services/operations.ts @@ -1,7 +1,7 @@ import { TokenResourceIdentifier } from '@/modules/core/domain/tokens/types' import { ServerInfo } from '@/modules/core/helpers/types' import { UserWithOptionalRole } from '@/modules/core/repositories/users' -import { EmailTemplateParams } from '@/modules/emails/services/emailRendering' +import { EmailTemplateParams } from '@/modules/emails/domain/operations' import { CreateInviteParams } from '@/modules/serverinvites/domain/operations' import { InviteResourceTarget, diff --git a/packages/server/modules/workspaces/domain/operations.ts b/packages/server/modules/workspaces/domain/operations.ts index adf28fe82f..66e39b30fa 100644 --- a/packages/server/modules/workspaces/domain/operations.ts +++ b/packages/server/modules/workspaces/domain/operations.ts @@ -127,7 +127,7 @@ export type GetWorkspaceCollaboratorsArgs = { /** * Optionally filter by workspace role(s) */ - roles?: string[] + roles?: WorkspaceRoles[] /** * Optionally filter by user name or email */ diff --git a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts index 6a93799832..a0ab38e230 100644 --- a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts +++ b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts @@ -202,6 +202,7 @@ import { import { Knex } from 'knex' import { getPaginatedItemsFactory } from '@/modules/shared/services/paginatedItems' import { InvalidWorkspacePlanStatus } from '@/modules/gatekeeper/errors/billing' +import { BadRequestError } from '@/modules/shared/errors' const eventBus = getEventBus() const getServerInfo = getServerInfoFactory({ db }) @@ -1033,12 +1034,25 @@ export = FF_WORKSPACES_MODULE_ENABLED return workspace?.role || null }, team: async (parent, args) => { + const roles = args.filter?.roles?.map((r) => { + const role = r as WorkspaceRoles + if (!Object.values(Roles.Workspace).includes(role)) { + throw new BadRequestError( + `The filter role ${role} is not a valid workspace role` + ) + } + return role + }) + const filter = removeNullOrUndefinedKeys({ + ...args?.filter, + roles + }) const team = await getPaginatedItemsFactory({ getItems: getWorkspaceCollaboratorsFactory({ db }), getTotalCount: getWorkspaceCollaboratorsTotalCountFactory({ db }) })({ workspaceId: parent.id, - filter: removeNullOrUndefinedKeys(args?.filter || {}), + filter, limit: args.limit, cursor: args.cursor ?? undefined })