Skip to content

Commit

Permalink
Merge pull request #3703 from specklesystems/gergo/web-2124-set-up-em…
Browse files Browse the repository at this point in the history
…ail-notifications-for-trial-expiration

Set up email notifications for trial expirations
  • Loading branch information
alemagio authored Dec 30, 2024
2 parents 6e92d12 + fa4022a commit 934b148
Show file tree
Hide file tree
Showing 19 changed files with 464 additions and 98 deletions.
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
14 changes: 12 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 @@ -14,6 +18,12 @@ export type ChangeExpiredTrialWorkspacePlanStatuses = (args: {
numberOfDays: number
}) => Promise<WorkspacePlan[]>

export type GetWorkspacesByPlanDaysTillExpiry = (args: {
daysTillExpiry: number
planValidFor: number
plan: WorkspacePlans
status: PlanStatuses
}) => Promise<Workspace[]>
export type GetWorkspacePlanByProjectId = ({
projectId
}: {
Expand Down
51 changes: 48 additions & 3 deletions packages/server/modules/gatekeeper/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 })
)
)
}
})
}

Expand Down
18 changes: 18 additions & 0 deletions packages/server/modules/gatekeeper/repositories/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Workspace>('workspaces'),
workspacePlans: (db: Knex) => db<WorkspacePlan>('workspace_plans'),
workspaceCheckoutSessions: (db: Knex) =>
db<CheckoutSession>('workspace_checkout_sessions'),
Expand Down Expand Up @@ -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 }) => {
Expand Down
Loading

0 comments on commit 934b148

Please sign in to comment.