Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Gergo/web 2124 set up email notifications for trial expiration #3703

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
56 changes: 56 additions & 0 deletions packages/server/modules/emails/domain/operations.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { UserRecord } from '@/modules/core/helpers/types'
import { EmailVerificationRecord } from '@/modules/emails/repositories'

/**
Expand All @@ -20,3 +21,58 @@ export type DeleteOldAndInsertNewVerification = (email: string) => Promise<strin
export type RequestNewEmailVerification = (emailId: string) => Promise<void>

export type RequestEmailVerification = (userId: string) => Promise<void>

export type SendEmailParams = {
from?: string
to: string | string[]
subject: string
text: string
html: string
}
export type SendEmail = (args: SendEmailParams) => Promise<boolean>

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<EmailContent>
45 changes: 5 additions & 40 deletions packages/server/modules/emails/services/emailRendering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
22 changes: 10 additions & 12 deletions packages/server/modules/emails/services/sending.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
}: SendEmailParams): Promise<boolean> => {
const transporter = getTransporter()
if (!transporter) {
logger.warn('No email transport present. Cannot send emails. Skipping send...')
Expand All @@ -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) {
Expand Down
14 changes: 6 additions & 8 deletions packages/server/modules/emails/services/verification/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -150,8 +148,8 @@ function buildEmailTemplateParams(verificationId: string): EmailTemplateParams {
}

type SendVerificationEmailDeps = {
sendEmail: typeof sendEmail
renderEmail: typeof renderEmail
sendEmail: SendEmail
renderEmail: RenderEmail
}

const sendVerificationEmailFactory =
Expand Down
6 changes: 2 additions & 4 deletions packages/server/modules/emails/tests/emailTemplating.spec.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down
15 changes: 13 additions & 2 deletions packages/server/modules/gatekeeper/domain/operations.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -13,3 +17,10 @@ export type WorkspaceFeatureAccessFunction = (args: {
export type ChangeExpiredTrialWorkspacePlanStatuses = (args: {
numberOfDays: number
}) => Promise<WorkspacePlan[]>

export type GetWorkspacesByPlanDaysTillExpiry = (args: {
daysTillExpiry: number
planValidFor: number
plan: WorkspacePlans
status: PlanStatuses
}) => Promise<Workspace[]>
64 changes: 61 additions & 3 deletions packages/server/modules/gatekeeper/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,22 @@ import {
import {
changeExpiredTrialWorkspacePlanStatusesFactory,
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'

const { FF_GATEKEEPER_MODULE_ENABLED, FF_BILLING_INTEGRATION_ENABLED } =
getFeatureFlags()
Expand Down Expand Up @@ -73,10 +82,59 @@ 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 trialWorkspacesExpireIn14Days = await getWorkspacesByPlanAge({
daysTillExpiry: 14,
planValidFor: trialValidForDays,
plan: 'starter',
status: 'trial'
})
if (trialWorkspacesExpireIn14Days.length) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alemagio I think we can remove the 14 days email for now. I initially wanted it but now I think it's a little too much to send it this long time before the trial ends. 3 days before and on the same day is enough for now.

Maybe we will send another type of email 14 days before just to check in with the user, but that can be a separate task. Additionally we'll also be adding more automated emails after the trial has expired to try and reactive the workspace. But also a separate task.

await Promise.all(
trialWorkspacesExpireIn14Days.map((workspace) =>
sendWorkspaceTrialEmail({ workspace, expiresInDays: 14 })
)
)
}
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 })
)
)
}
})
}

Expand Down
22 changes: 21 additions & 1 deletion packages/server/modules/gatekeeper/repositories/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,15 @@ import {
UpsertTrialWorkspacePlan,
UpsertUnpaidWorkspacePlan
} from '@/modules/gatekeeper/domain/billing'
import { ChangeExpiredTrialWorkspacePlanStatuses } from '@/modules/gatekeeper/domain/operations'
import {
ChangeExpiredTrialWorkspacePlanStatuses,
GetWorkspacesByPlanDaysTillExpiry
} from '@/modules/gatekeeper/domain/operations'
import { Workspace } from '@/modules/workspacesCore/domain/types'
import { Knex } from 'knex'

const tables = {
workspaces: (db: Knex) => db<Workspace>('workspaces'),
workspacePlans: (db: Knex) => db<WorkspacePlan>('workspace_plans'),
workspaceCheckoutSessions: (db: Knex) =>
db<CheckoutSession>('workspace_checkout_sessions'),
Expand Down Expand Up @@ -80,6 +85,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 }) => {
Expand Down
Loading