Cleans up code to better support previewing

Also fixed every template field being link wrapped
This commit is contained in:
Chris Anderson 2022-12-17 13:40:26 -06:00
parent 477c453876
commit b46dd783f4
19 changed files with 1716 additions and 1568 deletions

View file

@ -31,7 +31,14 @@
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"operator-linebreak": ["error", "before"],
"@typescript-eslint/no-unused-vars": ["error", { "vars": "all", "args": "after-used", "ignoreRestSiblings": true }]
"@typescript-eslint/no-unused-vars": ["error", {
"vars": "all",
"args": "after-used",
"ignoreRestSiblings": true,
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_"
}]
},
"globals": {
"NodeJS": true

2
.gitignore vendored
View file

@ -71,7 +71,7 @@ typings/
# dotenv environment variables file
.env
.env.test
.env.*
# parcel-bundler cache (https://parceljs.org/)
.cache

3015
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -55,7 +55,7 @@
"hashids": "^2.2.10",
"jsonpath": "^1.1.1",
"jsonwebtoken": "^8.5.1",
"knex": "^2.1.0",
"knex": "^2.3.0",
"koa": "^2.13.4",
"koa-body": "5.0.0",
"koa-jwt": "^4.0.3",

View file

@ -1,6 +1,5 @@
import Render, { Variables } from '../../render'
import { Variables, Wrap } from '../../render'
import { EmailTemplate } from '../../render/Template'
import { Email } from './Email'
import EmailProvider from './EmailProvider'
export default class EmailChannel {
@ -14,21 +13,16 @@ export default class EmailChannel {
}
}
async send(options: EmailTemplate, variables: Variables) {
async send(template: EmailTemplate, variables: Variables) {
if (!variables.user.email) throw new Error('Unable to send a text message to a user with no email.')
const message: Email = {
const compiled = template.compile(variables)
const email = {
...compiled,
to: variables.user.email,
subject: Render(options.subject, variables),
from: Render(options.from, variables),
html: Render(options.html_body, variables),
text: Render(options.text_body, variables),
html: Wrap(compiled.html, variables), // Add link and open tracking
}
if (options.reply_to) message.reply_to = Render(options.reply_to, variables)
if (options.cc) message.cc = Render(options.cc, variables)
if (options.bcc) message.bcc = Render(options.bcc, variables)
await this.provider.send(message)
await this.provider.send(email)
}
async verify(): Promise<boolean> {

View file

@ -1,5 +1,5 @@
import { PushTemplate } from '../../render/Template'
import Render, { Variables } from '../../render'
import { Variables } from '../../render'
import { PushProvider } from './PushProvider'
export default class PushChannel {
@ -12,22 +12,14 @@ export default class PushChannel {
}
}
async send(options: PushTemplate, variables: Variables) {
async send(template: PushTemplate, variables: Variables) {
// Find tokens from active devices with push enabled
const tokens = variables.user.pushEnabledDevices.map(device => device.token)
const custom = Object.keys(options.custom).reduce((body, key) => {
body[key] = Render(options.custom[key], variables)
return body
}, {} as Record<string, any>)
const push = {
tokens,
topic: options.topic,
title: Render(options.title, variables),
body: Render(options.body, variables),
custom,
...template.compile(variables),
}
await this.provider.send(push)

View file

@ -1,4 +1,4 @@
import { TextMessage, TextResponse } from './TextMessage'
import { InboundTextMessage, TextMessage, TextResponse } from './TextMessage'
import TextError from './TextError'
import { TextProvider } from './TextProvider'
import { ProviderParams, ProviderSchema } from '../Provider'
@ -8,6 +8,7 @@ import { createController } from '../ProviderService'
interface NexmoDataParams {
apiKey: string
apiSecret: string
phoneNumber: string
}
interface NexmoProviderParams extends ProviderParams {
@ -17,9 +18,10 @@ interface NexmoProviderParams extends ProviderParams {
export default class NexmoTextProvider extends TextProvider {
apiKey!: string
apiSecret!: string
phoneNumber!: string
async send(message: TextMessage): Promise<TextResponse> {
const { from, to, text } = message
const { to, text } = message
const response = await fetch('https://rest.nexmo.com/sms/json', {
method: 'POST',
headers: {
@ -29,7 +31,7 @@ export default class NexmoTextProvider extends TextProvider {
body: JSON.stringify({
api_key: this.apiKey,
api_secret: this.apiSecret,
from,
from: this.phoneNumber,
to,
text,
}),
@ -55,7 +57,7 @@ export default class NexmoTextProvider extends TextProvider {
}
// https://developer.vonage.com/messaging/sms/guides/inbound-sms
parseInbound(inbound: any): TextMessage {
parseInbound(inbound: any): InboundTextMessage {
return {
to: inbound.to,
from: inbound.msisdn,
@ -66,10 +68,11 @@ export default class NexmoTextProvider extends TextProvider {
static controllers(): Router {
const providerParams = ProviderSchema<NexmoProviderParams, NexmoDataParams>('nexmoTextProviderParams', {
type: 'object',
required: ['apiKey', 'apiSecret'],
required: ['apiKey', 'apiSecret', 'phoneNumber'],
properties: {
apiKey: { type: 'string' },
apiSecret: { type: 'string' },
phoneNumber: { type: 'string' },
},
})

View file

@ -1,7 +1,7 @@
import Router from '@koa/router'
import { ExternalProviderParams, ProviderSchema } from '../Provider'
import { createController } from '../ProviderService'
import { TextMessage, TextResponse } from './TextMessage'
import { InboundTextMessage, TextMessage, TextResponse } from './TextMessage'
import { TextProvider } from './TextProvider'
interface PlivoDataParams {
@ -24,7 +24,7 @@ export default class PlivoTextProvider extends TextProvider {
}
async send(message: TextMessage): Promise<TextResponse> {
const { from, to, text } = message
const { to, text } = message
const response = await fetch(`https://api.plivo.com/v1/Account/${this.authId}/Message/`, {
method: 'POST',
headers: {
@ -33,7 +33,7 @@ export default class PlivoTextProvider extends TextProvider {
'User-Agent': 'parcelvoy/v1 (+https://github.com/parcelvoy/platform)',
},
body: JSON.stringify({
src: from,
src: this.phoneNumber,
dst: to,
text,
}),
@ -52,7 +52,7 @@ export default class PlivoTextProvider extends TextProvider {
}
// https://www.plivo.com/docs/sms/use-cases/receive-sms/node
parseInbound(inbound: any): TextMessage {
parseInbound(inbound: any): InboundTextMessage {
return {
to: inbound.To,
from: inbound.From,

View file

@ -1,7 +1,7 @@
import { TextTemplate } from '../../render/Template'
import Render, { Variables } from '../../render'
import { Variables } from '../../render'
import { TextProvider } from './TextProvider'
import { TextMessage } from './TextMessage'
import { InboundTextMessage } from './TextMessage'
export default class TextChannel {
readonly provider: TextProvider
@ -13,19 +13,18 @@ export default class TextChannel {
}
}
async send(options: TextTemplate, variables: Variables) {
async send(template: TextTemplate, variables: Variables) {
if (!variables.user.phone) throw new Error('Unable to send a text message to a user with no phone number.')
const message = {
to: variables.user.phone,
from: options.from,
text: Render(options.text, variables),
...template.compile(variables),
}
await this.provider.send(message)
}
parseInbound(body: any): TextMessage {
parseInbound(body: any): InboundTextMessage {
return this.provider.parseInbound(body)
}
}

View file

@ -1,9 +1,12 @@
export interface TextMessage {
to: string
from: string
text: string
}
export interface InboundTextMessage extends TextMessage {
from: string
}
export interface TextResponse {
message: TextMessage
success: boolean

View file

@ -1,9 +1,9 @@
import Provider from '../Provider'
import { TextMessage, TextResponse } from './TextMessage'
import { InboundTextMessage, TextMessage, TextResponse } from './TextMessage'
export type TextProviderName = 'nexmo' | 'plivo' | 'twilio' | 'logger'
export abstract class TextProvider extends Provider {
abstract send(message: TextMessage): Promise<TextResponse>
abstract parseInbound(inbound: any): TextMessage
abstract parseInbound(inbound: any): InboundTextMessage
}

View file

@ -1,12 +1,13 @@
import Router from '@koa/router'
import { ExternalProviderParams, ProviderSchema } from '../Provider'
import { createController } from '../ProviderService'
import { TextMessage, TextResponse } from './TextMessage'
import { InboundTextMessage, TextMessage, TextResponse } from './TextMessage'
import { TextProvider } from './TextProvider'
interface TwilioDataParams {
accountSid: string
authToken: string
phoneNumber: string
}
interface TwilioProviderParams extends ExternalProviderParams {
@ -16,15 +17,16 @@ interface TwilioProviderParams extends ExternalProviderParams {
export default class TwilioTextProvider extends TextProvider {
accountSid!: string
authToken!: string
phoneNumber!: string
get apiKey(): string {
return Buffer.from(`${this.accountSid}:${this.authToken}`).toString('base64')
}
async send(message: TextMessage): Promise<TextResponse> {
const { from, to, text } = message
const { to, text } = message
const form = new FormData()
form.append('From', from)
form.append('From', this.phoneNumber)
form.append('To', to)
form.append('Body', text)
@ -50,7 +52,7 @@ export default class TwilioTextProvider extends TextProvider {
}
// https://www.twilio.com/docs/messaging/guides/webhook-request
parseInbound(inbound: any): TextMessage {
parseInbound(inbound: any): InboundTextMessage {
return {
to: inbound.To,
from: inbound.From,
@ -61,10 +63,11 @@ export default class TwilioTextProvider extends TextProvider {
static controllers(): Router {
const providerParams = ProviderSchema<TwilioProviderParams, TwilioDataParams>('twilioTextProviderParams', {
type: 'object',
required: ['accountSid', 'authToken'],
required: ['accountSid', 'authToken', 'phoneNumber'],
properties: {
accountSid: { type: 'string' },
authToken: { type: 'string' },
phoneNumber: { type: 'string' },
},
})

View file

@ -19,7 +19,8 @@ export default class EventPostJob extends Job {
}
static async handler({ project_id, event }: EventPostTrigger) {
const user = await getUserFromClientId(project_id, event.external_id)
const { anonymous_id, external_id } = event
const user = await getUserFromClientId(project_id, { anonymous_id, external_id })
if (!user) {
logger.error({ project_id, event }, 'job:event_post:unknown-user')
return

View file

@ -8,8 +8,8 @@ import { getUser } from '../users/UserRepository'
import { combineURLs, decodeHashid, encodeHashid } from '../utilities'
export interface TrackedLinkParams {
user: User | number
campaign: Campaign | number
userId: number
campaignId: number
}
interface TrackedLinkParts extends TrackedLinkParams {
@ -18,10 +18,8 @@ interface TrackedLinkParts extends TrackedLinkParams {
}
export const paramsToEncodedLink = (params: TrackedLinkParts): string => {
const userId = params.user instanceof User ? params.user.id : params.user
const campaignId = params.campaign instanceof Campaign ? params.campaign.id : params.campaign
const hashUserId = encodeHashid(userId)
const hashCampaignId = encodeHashid(campaignId)
const hashUserId = encodeHashid(params.userId)
const hashCampaignId = encodeHashid(params.campaignId)
const baseUrl = combineURLs([App.main.env.baseUrl, params.path])
const url = new URL(baseUrl)
@ -98,7 +96,7 @@ export const trackLinkEvent = async (parts: TrackedLinkExport, eventName: string
const job = EventPostJob.from({
project_id: user.project_id,
event: {
user_id: user.external_id,
external_id: user.external_id,
name: eventName,
data: {
campaign_id: campaign.id,

View file

@ -1,3 +1,5 @@
import Render, { Variables } from '.'
import { Webhook } from '../channels/webhook/Webhook'
import { ChannelType } from '../config/channels'
import Model, { ModelParams } from '../core/Model'
import { templateScreenshotUrl } from './TemplateService'
@ -31,9 +33,18 @@ export default class Template extends Model {
}
export type TemplateParams = Omit<Template, ModelParams | 'map' | 'screenshotUrl'>
export type TemplateType = EmailTemplate | TextTemplate | PushTemplate | WebhookTemplate
export interface CompiledEmail {
from: string
cc?: string
bcc?: string
reply_to?: string
subject: string
text: string
html: string
}
export class EmailTemplate extends Template {
declare type: 'email'
from!: string
@ -41,8 +52,8 @@ export class EmailTemplate extends Template {
bcc?: string
reply_to?: string
subject!: string
text_body!: string
html_body!: string
text!: string
html!: string
parseJson(json: any) {
super.parseJson(json)
@ -51,23 +62,49 @@ export class EmailTemplate extends Template {
this.cc = json?.data.cc
this.bcc = json?.data.bcc
this.reply_to = json?.data.reply_to
this.subject = json?.data.subject
this.text_body = json?.data.text_body
this.html_body = json?.data.html_body
this.subject = json?.data.subject ?? ''
this.text = json?.data.text ?? ''
this.html = json?.data.html ?? ''
}
compile(variables: Variables): CompiledEmail {
const email: CompiledEmail = {
subject: Render(this.subject, variables),
from: Render(this.from, variables),
html: Render(this.html, variables),
text: Render(this.text, variables),
}
if (this.reply_to) email.reply_to = Render(this.reply_to, variables)
if (this.cc) email.cc = Render(this.cc, variables)
if (this.bcc) email.bcc = Render(this.bcc, variables)
return email
}
}
export interface CompiledText {
text: string
}
export class TextTemplate extends Template {
declare type: 'text'
from!: string
text!: string
parseJson(json: any) {
super.parseJson(json)
this.from = json?.data.from
this.text = json?.data.text
}
compile(variables: Variables): CompiledText {
return { text: Render(this.text, variables) }
}
}
export interface CompiledPush {
title: string
topic: string
body: string
custom: Record<string, any>
}
export class PushTemplate extends Template {
@ -85,6 +122,20 @@ export class PushTemplate extends Template {
this.body = json?.data.body
this.custom = json?.data.custom
}
compile(variables: Variables): CompiledPush {
const custom = Object.keys(this.custom).reduce((body, key) => {
body[key] = Render(this.custom[key], variables)
return body
}, {} as Record<string, any>)
return {
topic: this.topic,
title: Render(this.title, variables),
body: Render(this.body, variables),
custom,
}
}
}
export class WebhookTemplate extends Template {
@ -92,7 +143,6 @@ export class WebhookTemplate extends Template {
method!: 'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT'
endpoint!: string
body!: Record<string, any>
query: Record<string, string | string[]> = {}
headers: Record<string, string> = {}
parseJson(json: any) {
@ -101,7 +151,27 @@ export class WebhookTemplate extends Template {
this.method = json?.data.method
this.endpoint = json?.data.endpoint
this.body = json?.data.body
this.query = json?.data.query || {}
this.headers = json?.data.headers || {}
}
compile(variables: Variables): Webhook {
const headers = Object.keys(this.headers).reduce((headers, key) => {
headers[key] = Render(this.headers[key], variables)
return headers
}, {} as Record<string, string>)
const body = Object.keys(this.body).reduce((body, key) => {
body[key] = Render(this.body[key], variables)
return body
}, {} as Record<string, any>)
const endpoint = Render(this.endpoint, variables)
const method = this.method
return {
endpoint,
method,
headers,
body,
}
}
}

View file

@ -4,7 +4,10 @@ import { JSONSchemaType, validate } from '../core/validate'
import { searchParamsSchema } from '../core/searchParams'
import { extractQueryParams } from '../utilities'
import Template, { TemplateParams } from './Template'
import { createTemplate, getTemplate, pagedTemplates, renderTemplate, updateTemplate } from './TemplateService'
import { createTemplate, getTemplate, pagedTemplates, updateTemplate } from './TemplateService'
import { Variables } from '.'
import { User } from '../users/User'
import { UserEvent } from '../users/UserEvent'
const router = new Router<
ProjectState & { template?: Template }
@ -32,7 +35,7 @@ const templateParams: JSONSchemaType<TemplateParams> = {
},
data: {
type: 'object',
required: ['from', 'subject', 'text_body', 'html_body'],
required: ['from', 'subject', 'text', 'html'],
properties: {
from: { type: 'string' },
cc: {
@ -48,8 +51,8 @@ const templateParams: JSONSchemaType<TemplateParams> = {
nullable: true,
},
subject: { type: 'string' },
text_body: { type: 'string' },
html_body: { type: 'string' },
text: { type: 'string' },
html: { type: 'string' },
},
} as any,
},
@ -68,9 +71,8 @@ const templateParams: JSONSchemaType<TemplateParams> = {
},
data: {
type: 'object',
required: ['from', 'text'],
required: ['text'],
properties: {
from: { type: 'string' },
text: { type: 'string' },
},
} as any,
@ -129,9 +131,15 @@ router.patch('/:templateId', async ctx => {
ctx.body = await updateTemplate(ctx.state.template!.id, payload)
})
router.get('/:templateId/preview', async ctx => {
router.post('/:templateId/preview', async ctx => {
const payload = ctx.request.body as Variables
const template = ctx.state.template!.map()
ctx.body = renderTemplate(template)
ctx.body = template.compile({
user: User.fromJson({ ...payload.user, data: payload.user }),
event: UserEvent.fromJson(payload.event || {}),
context: payload.context || {},
})
})
export default router

View file

@ -4,6 +4,7 @@ import Template, { TemplateParams, TemplateType } from './Template'
import nodeHtmlToImage from 'node-html-to-image'
import App from '../app'
import TemplateSnapshotJob from './TemplateSnapshotJob'
import { prune } from '../utilities'
export const pagedTemplates = async (params: SearchParams, projectId: number) => {
return await Template.searchParams(
@ -35,7 +36,7 @@ export const createTemplate = async (projectId: number, params: TemplateParams)
}
export const updateTemplate = async (templateId: number, params: TemplateParams) => {
const template = await Template.updateAndFetch(templateId, params)
const template = await Template.updateAndFetch(templateId, prune(params))
App.main.queue.enqueue(
TemplateSnapshotJob.from({ project_id: template.project_id, template_id: template.id }),
@ -46,7 +47,7 @@ export const updateTemplate = async (templateId: number, params: TemplateParams)
export const renderTemplate = (template: TemplateType) => {
if (template.type === 'email') {
return template.html_body
return template.html
} else if (template.type === 'text') {
return template.text
} else if (template.type === 'push') {

View file

@ -19,6 +19,11 @@ export interface Variables {
event?: Record<string, any>
}
export interface TrackingParams {
user: User
campaign: number
}
const loadHelper = (helper: Record<string, any>) => {
const keys = Object.keys(helper)
const values = Object.values(helper)
@ -37,16 +42,25 @@ export const Compile = (template: string, context: Record<string, any> = {}) =>
return Handlebars.compile(template)(context)
}
export const Wrap = (html: string, { user, context }: Variables) => {
const trackingParams = { userId: user.id, campaignId: context.campaign_id }
html = clickWrapHTML(html, trackingParams)
html = openWrapHtml(html, trackingParams)
return html
}
export default (template: string, { user, event, context }: Variables) => {
const trackingParams = { user, campaign: context.campaign_id }
let html = Compile(template, {
const trackingParams = { userId: user.id, campaignId: context?.campaign_id }
console.log('context', {
user: user.flatten(),
event,
context,
unsubscribeEmailUrl: unsubscribeEmailLink(trackingParams),
})
return Compile(template, {
user: user.flatten(),
event,
context,
unsubscribeEmailUrl: unsubscribeEmailLink(trackingParams),
})
html = clickWrapHTML(html, trackingParams)
html = openWrapHtml(html, trackingParams)
return html
}

View file

@ -13,6 +13,14 @@ export const randomInt = (min = 0, max = 100): number => {
return Math.floor(Math.random() * (max - min + 1)) + min
}
export const prune = (obj: Record<string, any>): Record<string, any> => {
return Object.fromEntries(
Object.entries(obj)
.filter(([_, v]) => v != null && v !== '')
.map(([k, v]) => [k, v === Object(v) ? prune(v) : v]),
)
}
export const snakeCase = (str: string): string => str.match(/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g)
?.map(x => x.toLowerCase())
.join('_') ?? ''