Provider Improvements (#123)

* Fixes push notification provider

Improves provider schema form building

* Minor tweak
This commit is contained in:
Chris Anderson 2023-04-12 22:36:04 -05:00 committed by GitHub
parent db83ff9ea6
commit 6adc68215b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 119 additions and 34 deletions

View file

@ -38,7 +38,10 @@ export default class MailgunEmailProvider extends EmailProvider {
type: 'object', type: 'object',
required: ['api_key', 'domain'], required: ['api_key', 'domain'],
properties: { properties: {
api_key: { type: 'string' }, api_key: {
type: 'string',
title: 'API Key',
},
domain: { type: 'string' }, domain: { type: 'string' },
webhook_signing_key: { webhook_signing_key: {
type: 'string', type: 'string',

View file

@ -43,8 +43,14 @@ export default class SESEmailProvider extends EmailProvider {
type: 'object', type: 'object',
required: ['accessKeyId', 'secretAccessKey'], required: ['accessKeyId', 'secretAccessKey'],
properties: { properties: {
accessKeyId: { type: 'string' }, accessKeyId: {
secretAccessKey: { type: 'string' }, type: 'string',
title: 'Access Key ID',
},
secretAccessKey: {
type: 'string',
title: 'Secret Access Key',
},
}, },
}, },
}, },

View file

@ -12,6 +12,7 @@ interface APNParams {
teamId: string teamId: string
} }
production: boolean production: boolean
bundleId: string
} }
interface FCMParams { interface FCMParams {
@ -46,27 +47,49 @@ export default class LocalPushProvider extends PushProvider {
type: 'object', type: 'object',
nullable: true, nullable: true,
required: ['production', 'token'], required: ['production', 'token'],
title: 'APN',
description: 'Settings for Apple Push Notifications to send messages to iOS devices.',
properties: { properties: {
production: { production: {
type: 'boolean', type: 'boolean',
description: 'Leave unchecked if you are wanting to send to sandbox devices only.',
}, },
token: { token: {
type: 'object', type: 'object',
required: ['key', 'keyId', 'teamId'], required: ['key', 'keyId', 'teamId'],
properties: { properties: {
key: { type: 'string' }, key: {
keyId: { type: 'string' }, type: 'string',
teamId: { type: 'string' }, minLength: 80,
},
keyId: {
type: 'string',
title: 'Key ID',
},
teamId: {
type: 'string',
title: 'Team ID',
},
}, },
}, },
bundleId: {
type: 'string',
title: 'Bundle ID',
},
}, },
}, },
fcm: { fcm: {
type: 'object', type: 'object',
nullable: true, nullable: true,
required: ['id'], required: ['id'],
title: 'FCM',
description: 'Settings for Firebase Cloud Messaging to send messages to Android devices.',
properties: { properties: {
id: { type: 'string' }, id: {
type: 'string',
title: 'Server Key',
minLength: 80,
},
}, },
}, },
}, },
@ -81,10 +104,11 @@ export default class LocalPushProvider extends PushProvider {
} }
async send(push: Push): Promise<PushResponse> { async send(push: Push): Promise<PushResponse> {
const { tokens, title, topic, body, custom } = push // TODO: Need a better way of bubbling up errors
const { tokens, title, body, custom } = push
const response = await this.transport.send(typeof tokens === 'string' ? [tokens] : tokens, { const response = await this.transport.send(typeof tokens === 'string' ? [tokens] : tokens, {
title, title,
topic, topic: this.apn?.bundleId,
body, body,
custom, custom,
}) })

View file

@ -7,6 +7,7 @@ export default class PushChannel {
constructor(provider?: PushProvider) { constructor(provider?: PushProvider) {
if (provider) { if (provider) {
this.provider = provider this.provider = provider
this.provider.boot?.()
} else { } else {
throw new Error('A valid push notification provider must be defined!') throw new Error('A valid push notification provider must be defined!')
} }
@ -17,6 +18,9 @@ export default class PushChannel {
// Find tokens from active devices with push enabled // Find tokens from active devices with push enabled
const tokens = variables.user.pushEnabledDevices.map(device => device.token) const tokens = variables.user.pushEnabledDevices.map(device => device.token)
// If no tokens, don't send
if (tokens?.length <= 0) return
const push = { const push = {
tokens, tokens,
...template.compile(variables), ...template.compile(variables),

View file

@ -30,8 +30,14 @@ export default class NexmoTextProvider extends TextProvider {
type: 'object', type: 'object',
required: ['api_key', 'api_secret', 'phone_number'], required: ['api_key', 'api_secret', 'phone_number'],
properties: { properties: {
api_key: { type: 'string' }, api_key: {
api_secret: { type: 'string' }, type: 'string',
title: 'API Key',
},
api_secret: {
type: 'string',
title: 'API Secret',
},
phone_number: { type: 'string' }, phone_number: { type: 'string' },
}, },
}) })

View file

@ -29,7 +29,10 @@ export default class PlivoTextProvider extends TextProvider {
type: 'object', type: 'object',
required: ['auth_id', 'auth_token', 'phone_number'], required: ['auth_id', 'auth_token', 'phone_number'],
properties: { properties: {
auth_id: { type: 'string' }, auth_id: {
type: 'string',
title: 'Auth ID',
},
auth_token: { type: 'string' }, auth_token: { type: 'string' },
phone_number: { type: 'string' }, phone_number: { type: 'string' },
}, },

View file

@ -37,7 +37,10 @@ export default class TwilioTextProvider extends TextProvider {
type: 'object', type: 'object',
required: ['account_sid', 'auth_token', 'phone_number'], required: ['account_sid', 'auth_token', 'phone_number'],
properties: { properties: {
account_sid: { type: 'string' }, account_sid: {
type: 'string',
title: 'Account SID',
},
auth_token: { type: 'string' }, auth_token: { type: 'string' },
phone_number: { type: 'string' }, phone_number: { type: 'string' },
}, },

View file

@ -167,7 +167,7 @@ export class PushTemplate extends Template {
this.title = json?.data.title this.title = json?.data.title
this.topic = json?.data.topic this.topic = json?.data.topic
this.body = json?.data.body this.body = json?.data.body
this.custom = json?.data.custom this.custom = json?.data.custom ?? {}
} }
compile(variables: Variables): CompiledPush { compile(variables: Variables): CompiledPush {

View file

@ -9,6 +9,8 @@ import { UserEvent } from '../users/UserEvent'
import { loadTextChannel } from '../providers/text' import { loadTextChannel } from '../providers/text'
import { RequestError } from '../core/errors' import { RequestError } from '../core/errors'
import CampaignError from '../campaigns/CampaignError' import CampaignError from '../campaigns/CampaignError'
import { loadPushChannel } from '../providers/push'
import { getUserFromEmail, getUserFromPhone } from '../users/UserRepository'
export const pagedTemplates = async (params: SearchParams, projectId: number) => { export const pagedTemplates = async (params: SearchParams, projectId: number) => {
return await Template.searchParams( return await Template.searchParams(
@ -65,20 +67,33 @@ export const sendProof = async (template: TemplateType, variables: Variables, re
const campaign = await getCampaign(template.campaign_id, template.project_id) const campaign = await getCampaign(template.campaign_id, template.project_id)
if (!campaign) throw new RequestError(CampaignError.CampaignDoesNotExist) if (!campaign) throw new RequestError(CampaignError.CampaignDoesNotExist)
const event = UserEvent.fromJson(variables.event || {})
const context = variables.context || {}
const projectId = template.project_id
if (template.type === 'email') { if (template.type === 'email') {
const channel = await loadEmailChannel(campaign.provider_id, template.project_id) const channel = await loadEmailChannel(campaign.provider_id, projectId)
await channel?.send(template, { await channel?.send(template, {
user: User.fromJson({ ...variables.user, data: variables.user, email: recipient }), user: User.fromJson({ ...variables.user, data: variables.user, email: recipient }),
event: UserEvent.fromJson(variables.event || {}), event,
context: variables.context || {}, context,
}) })
} else if (template.type === 'text') { } else if (template.type === 'text') {
const channel = await loadTextChannel(campaign.provider_id, template.project_id) const channel = await loadTextChannel(campaign.provider_id, projectId)
await channel?.send(template, { await channel?.send(template, {
user: User.fromJson({ ...variables.user, data: variables.user, phone: recipient }), user: User.fromJson({ ...variables.user, data: variables.user, phone: recipient }),
event: UserEvent.fromJson(variables.event || {}), event,
context: variables.context || {}, context,
})
} else if (template.type === 'push') {
const channel = await loadPushChannel(campaign.provider_id, projectId)
const user = await getUserFromEmail(projectId, recipient) ?? await getUserFromPhone(projectId, recipient)
if (!user) throw new RequestError('Unable to find a user matching the criteria.')
user.data = { ...variables.user, ...user.data }
await channel?.send(template, {
user,
event,
context,
}) })
} else { } else {
throw new RequestError('Sending template proofs is only supported for email and text message types as this time.') throw new RequestError('Sending template proofs is only supported for email and text message types as this time.')

View file

@ -255,7 +255,7 @@ export const subscriptionCreateSchema: JSONSchemaType<SubscriptionParams> = {
}, },
channel: { channel: {
type: 'string', type: 'string',
enum: ['email', 'text', 'webhook'], enum: ['email', 'text', 'push', 'webhook'],
}, },
}, },
additionalProperties: false, additionalProperties: false,

View file

@ -26,7 +26,6 @@ export default function FormWrapper<T extends FieldValues>({
}) })
const handleSubmit = form.handleSubmit(async data => { const handleSubmit = form.handleSubmit(async data => {
console.log('submitting', data)
setIsLoading(true) setIsLoading(true)
onSubmit(data, navigate).finally(() => { onSubmit(data, navigate).finally(() => {
setIsLoading(false) setIsLoading(false)

View file

@ -2,4 +2,16 @@
border: 1px solid var(--color-grey); border: 1px solid var(--color-grey);
border-radius: var(--border-radius); border-radius: var(--border-radius);
padding: 10px; padding: 10px;
}
.ui-schema-form > h5 {
margin-top: 15px;
margin-bottom: 2px;
}
.ui-schema-form > p {
margin: 2px 0 10px;
color: var(--color-primary-soft);
font-weight: 400;
font-size: 14px;
} }

View file

@ -5,18 +5,22 @@ import SwitchField from './SwitchField'
interface Schema { interface Schema {
type: 'string' | 'number' | 'boolean' | 'object' type: 'string' | 'number' | 'boolean' | 'object'
title?: string
description?: string
properties?: Record<string, Schema> properties?: Record<string, Schema>
required?: string[] required?: string[]
minLength?: number
} }
interface SchemaProps { interface SchemaProps {
title?: string title?: string
description?: string
parent: string parent: string
schema: Schema schema: Schema
form: any form: any
} }
export default function SchemaFields({ title, parent, form, schema }: SchemaProps) { export default function SchemaFields({ title, description, parent, form, schema }: SchemaProps) {
if (!schema?.properties) { if (!schema?.properties) {
return <></> return <></>
} }
@ -25,28 +29,40 @@ export default function SchemaFields({ title, parent, form, schema }: SchemaProp
const keys = Object.keys(schema.properties) const keys = Object.keys(schema.properties)
return <div className="ui-schema-form"> return <div className="ui-schema-form">
{title && <h5>{snakeToTitle(title)}</h5>} {title && <h5>{snakeToTitle(title)}</h5>}
{description && <p>{description}</p> }
<div className="ui-schema-fields"> <div className="ui-schema-fields">
{keys.map(key => { {keys.map(key => {
const item = props[key] const item = props[key]
const required = schema.required?.includes(key) const required = schema.required?.includes(key)
const title = item.title ?? snakeToTitle(key)
if (item.type === 'string' || item.type === 'number') { if (item.type === 'string' || item.type === 'number') {
return <TextInput.Field return <TextInput.Field
key={key} key={key}
form={form} form={form}
name={`${parent}.${key}`} name={`${parent}.${key}`}
label={snakeToTitle(key)} label={title}
subtitle={item.description}
required={required} required={required}
textarea={(item.minLength ?? 0) >= 80}
/> />
} else if (item.type === 'boolean') { } else if (item.type === 'boolean') {
return <SwitchField return <SwitchField
key={key} key={key}
form={form} form={form}
name={`${parent}.${key}`} name={`${parent}.${key}`}
label={snakeToTitle(key)} label={title}
subtitle={item.description}
required={required} required={required}
/> />
} else if (item.type === 'object') { } else if (item.type === 'object') {
return SchemaFields({ title: key, form, parent: `${parent}.${key}`, schema: item }) return <SchemaFields
key={key}
form={form}
title={title}
description={item.description}
parent={`${parent}.${key}`}
schema={item}
/>
} }
return 'no key' return 'no key'
})} })}

View file

@ -32,7 +32,7 @@ export default function SwitchField<X extends FieldValues, P extends FieldPath<X
id={id} id={id}
checked={checked} checked={checked}
onChange={(event) => onChange?.(event.target.checked)} onChange={(event) => onChange?.(event.target.checked)}
{...form?.register(name, { disabled, required })} {...form?.register(name, { disabled })}
/> />
<div className="slider round"></div> <div className="slider round"></div>
</div> </div>

View file

@ -66,7 +66,7 @@ const SendProof = ({ open, onClose, onSubmit, type }: SendProofProps) => {
open={open} open={open}
onClose={onClose} onClose={onClose}
title="Send Proof" title="Send Proof"
description={`Enter the ${type === 'email' ? 'email address' : 'phone number'} of the recipient you want to receive the proof of this template.`}> description={`Enter the ${type === 'email' ? 'email address' : 'email or phone number'} of the recipient you want to receive the proof of this template.`}>
<FormWrapper<TemplateProofParams> <FormWrapper<TemplateProofParams>
onSubmit={async ({ recipient }) => await onSubmit(recipient)}> onSubmit={async ({ recipient }) => await onSubmit(recipient)}>
{form => ( {form => (

View file

@ -64,7 +64,6 @@ const TextForm = ({ form }: { form: UseFormReturn<TemplateUpdateParams, any> })
const PushTable = ({ data }: { data: PushTemplateData }) => <InfoTable rows={{ const PushTable = ({ data }: { data: PushTemplateData }) => <InfoTable rows={{
Title: data.title, Title: data.title,
Body: data.body, Body: data.body,
Topic: data.topic,
}} /> }} />
const PushForm = ({ form }: { form: UseFormReturn<TemplateUpdateParams, any> }) => <> const PushForm = ({ form }: { form: UseFormReturn<TemplateUpdateParams, any> }) => <>
@ -79,11 +78,6 @@ const PushForm = ({ form }: { form: UseFormReturn<TemplateUpdateParams, any> })
label="Body" label="Body"
textarea textarea
required /> required />
<TextInput.Field
form={form}
name="data.topic"
label="Topic"
required />
</> </>
interface TemplateDetailProps { interface TemplateDetailProps {