mirror of
https://fast.feibisi.com/https://github.com/parcelvoy/platform.git
synced 2025-09-02 12:36:40 +08:00
Improve provider unsubscribes and lookups
This commit is contained in:
parent
e2fb6b9155
commit
4544f07d65
55 changed files with 593 additions and 250 deletions
155
apps/platform/package-lock.json
generated
155
apps/platform/package-lock.json
generated
|
@ -30,6 +30,7 @@
|
|||
"handlebars": "^4.7.7",
|
||||
"handlebars-utils": "^1.0.6",
|
||||
"hashids": "^2.2.10",
|
||||
"html-to-text": "^9.0.4",
|
||||
"jsonpath": "^1.1.1",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"knex": "^2.3.0",
|
||||
|
@ -47,6 +48,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@types/busboy": "^1.5.0",
|
||||
"@types/html-to-text": "^9.0.0",
|
||||
"@types/jest": "^28.1.6",
|
||||
"@types/jsonpath": "^0.2.0",
|
||||
"@types/jsonwebtoken": "^8.5.9",
|
||||
|
@ -2900,6 +2902,18 @@
|
|||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@selderee/plugin-htmlparser2": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.10.0.tgz",
|
||||
"integrity": "sha512-gW69MEamZ4wk1OsOq1nG1jcyhXIQcnrsX5JwixVw/9xaiav8TCyjESAruu1Rz9yyInhgBXxkNwMeygKnN2uxNA==",
|
||||
"dependencies": {
|
||||
"domhandler": "^5.0.3",
|
||||
"selderee": "^0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
}
|
||||
},
|
||||
"node_modules/@sinclair/typebox": {
|
||||
"version": "0.24.51",
|
||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz",
|
||||
|
@ -3088,6 +3102,12 @@
|
|||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/html-to-text": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/html-to-text/-/html-to-text-9.0.0.tgz",
|
||||
"integrity": "sha512-FnF3p2FJZ1kJT/0C/lmBzw7HSlH3RhtACVYyrwUsJoCmFNuiLpusWT2FWWB7P9A48CaYpvD6Q2fprn7sZeffpw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/http-assert": {
|
||||
"version": "1.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.3.tgz",
|
||||
|
@ -4555,7 +4575,6 @@
|
|||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.0.tgz",
|
||||
"integrity": "sha512-z2wJZXrmeHdvYJp/Ux55wIjqo81G5Bp4c+oELTW+7ar6SogWHajt5a9gO3s3IDaGSAXjDk0vlQKN3rms8ab3og==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
@ -4675,6 +4694,57 @@
|
|||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.0.1.tgz",
|
||||
"integrity": "sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz",
|
||||
|
@ -4753,6 +4823,17 @@
|
|||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.4.0.tgz",
|
||||
"integrity": "sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA==",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/error-ex": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
|
||||
|
@ -6192,6 +6273,39 @@
|
|||
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/html-to-text": {
|
||||
"version": "9.0.4",
|
||||
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.4.tgz",
|
||||
"integrity": "sha512-ckrQ5N2yZS7qSgKxUbqrBZ02NxD5cSy7KuYjCNIf+HWbdzY3fbjYjQsoRIl6TiaZ4+XWOi0ggFP8/pmgCK/o+A==",
|
||||
"dependencies": {
|
||||
"@selderee/plugin-htmlparser2": "^0.10.0",
|
||||
"deepmerge": "^4.3.0",
|
||||
"dom-serializer": "^2.0.0",
|
||||
"htmlparser2": "^8.0.1",
|
||||
"selderee": "^0.10.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.1.tgz",
|
||||
"integrity": "sha512-4lVbmc1diZC7GUJQtRQ5yBAeUCL1exyMwmForWkRLnwyzWBFxN633SALPMGYaWZvKe9j1pRZJpauvmxENSp/EA==",
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"domutils": "^3.0.1",
|
||||
"entities": "^4.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http_ece": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.1.0.tgz",
|
||||
|
@ -7854,6 +7968,14 @@
|
|||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/leac": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
|
||||
"integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==",
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
}
|
||||
},
|
||||
"node_modules/leven": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
||||
|
@ -8683,6 +8805,18 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/parseley": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.11.0.tgz",
|
||||
"integrity": "sha512-VfcwXlBWgTF+unPcr7yu3HSSA6QUdDaDnrHcytVfj5Z8azAyKBDrYnSIfeSxlrEayndNcLmrXzg+Vxbo6DWRXQ==",
|
||||
"dependencies": {
|
||||
"leac": "^0.6.0",
|
||||
"peberminta": "^0.8.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
|
@ -8736,6 +8870,14 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/peberminta": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.8.0.tgz",
|
||||
"integrity": "sha512-YYEs+eauIjDH5nUEGi18EohWE0nV2QbGTqmxQcqgZ/0g+laPCQmuIqq7EBLVi9uim9zMgfJv0QBZEnQ3uHw/Tw==",
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
}
|
||||
},
|
||||
"node_modules/performance-now": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||
|
@ -9445,6 +9587,17 @@
|
|||
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz",
|
||||
"integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="
|
||||
},
|
||||
"node_modules/selderee": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/selderee/-/selderee-0.10.0.tgz",
|
||||
"integrity": "sha512-DEL/RW/f4qLw/NrVg97xKaEBC8IpzIG2fvxnzCp3Z4yk4jQ3MXom+Imav9wApjxX2dfS3eW7x0DXafJr85i39A==",
|
||||
"dependencies": {
|
||||
"parseley": "^0.11.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.3.8",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz",
|
||||
|
|
|
@ -24,6 +24,7 @@
|
|||
"handlebars": "^4.7.7",
|
||||
"handlebars-utils": "^1.0.6",
|
||||
"hashids": "^2.2.10",
|
||||
"html-to-text": "^9.0.4",
|
||||
"jsonpath": "^1.1.1",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"knex": "^2.3.0",
|
||||
|
@ -50,6 +51,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@types/busboy": "^1.5.0",
|
||||
"@types/html-to-text": "^9.0.0",
|
||||
"@types/jest": "^28.1.6",
|
||||
"@types/jsonpath": "^0.2.0",
|
||||
"@types/jsonwebtoken": "^8.5.9",
|
||||
|
|
|
@ -8,7 +8,7 @@ import SubscriptionController, { publicRouter as PublicSubscriptionController }
|
|||
import JourneyController from '../journey/JourneyController'
|
||||
import ImageController from '../storage/ImageController'
|
||||
import AuthController from '../auth/AuthController'
|
||||
import ProviderController from '../providers/ProviderController'
|
||||
import { adminRouter as AdminProviderController, publicRouter as PublicProviderController } from '../providers/ProviderController'
|
||||
import LinkController from '../render/LinkController'
|
||||
import TemplateController from '../render/TemplateController'
|
||||
import UserController from '../users/UserController'
|
||||
|
@ -73,7 +73,7 @@ export const projectRouter = (prefix = '/projects/:project') => {
|
|||
JourneyController,
|
||||
ImageController,
|
||||
TemplateController,
|
||||
ProviderController,
|
||||
AdminProviderController,
|
||||
ProjectAdminController,
|
||||
ProjectApiKeyController,
|
||||
UserController,
|
||||
|
@ -120,6 +120,7 @@ export const publicRouter = () => {
|
|||
return register(router,
|
||||
AuthController,
|
||||
PublicSubscriptionController,
|
||||
PublicProviderController,
|
||||
LinkController,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import Campaign from '../campaigns/Campaign'
|
||||
import { updateSendState } from '../campaigns/CampaignService'
|
||||
import Project from '../projects/Project'
|
||||
import { RenderContext } from '../render'
|
||||
import Template, { TemplateType } from '../render/Template'
|
||||
import { User } from '../users/User'
|
||||
import { UserEvent } from '../users/UserEvent'
|
||||
|
@ -12,6 +13,7 @@ interface MessageTriggerHydrated<T> {
|
|||
campaign: Campaign
|
||||
template: T
|
||||
project: Project
|
||||
context: RenderContext
|
||||
}
|
||||
|
||||
export async function loadSendJob<T extends TemplateType>({ campaign_id, user_id, event_id }: MessageTrigger): Promise<MessageTriggerHydrated<T> | undefined> {
|
||||
|
@ -42,5 +44,11 @@ export async function loadSendJob<T extends TemplateType>({ campaign_id, user_id
|
|||
return
|
||||
}
|
||||
|
||||
return { campaign, template: template.map() as T, user, project, event }
|
||||
const context = {
|
||||
campaign_id: campaign.id,
|
||||
template_id: template.id,
|
||||
subscription_id: campaign.subscription_id,
|
||||
}
|
||||
|
||||
return { campaign, template: template.map() as T, user, project, event, context }
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import Router from '@koa/router'
|
||||
import Model, { ModelParams } from '../core/Model'
|
||||
import { JSONSchemaType } from '../core/validate'
|
||||
|
||||
|
@ -86,3 +87,8 @@ export type ProviderMap<T extends Provider> = (record: any) => T
|
|||
export type ProviderParams = Omit<Provider, ModelParams>
|
||||
|
||||
export type ExternalProviderParams = Omit<ProviderParams, 'group'>
|
||||
|
||||
export interface ProviderControllers {
|
||||
admin: Router
|
||||
public?: Router
|
||||
}
|
||||
|
|
|
@ -1,38 +1,61 @@
|
|||
import Router from '@koa/router'
|
||||
import { ProjectState } from '../auth/AuthMiddleware'
|
||||
import { searchParamsSchema } from '../core/searchParams'
|
||||
import { extractQueryParams } from '../utilities'
|
||||
import { decodeHashid, extractQueryParams } from '../utilities'
|
||||
import { loadAnalyticsControllers } from './analytics'
|
||||
import { loadEmailControllers } from './email'
|
||||
import { ProviderMeta } from './Provider'
|
||||
import { getProvider } from './ProviderRepository'
|
||||
import { allProviders, pagedProviders } from './ProviderService'
|
||||
import { loadPushControllers } from './push'
|
||||
import { loadTextControllers } from './text'
|
||||
import { loadWebhookControllers } from './webhook'
|
||||
|
||||
const router = new Router<ProjectState>({
|
||||
const adminRouter = new Router<ProjectState>({
|
||||
prefix: '/providers',
|
||||
})
|
||||
|
||||
const publicRouter = new Router({
|
||||
prefix: '/providers/:hash',
|
||||
})
|
||||
publicRouter.param('hash', async (value, ctx, next) => {
|
||||
try {
|
||||
const providerId = decodeHashid(value)
|
||||
if (!providerId) {
|
||||
ctx.throw(404)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.state.provider = await getProvider(providerId)
|
||||
if (!ctx.state.provider) {
|
||||
ctx.throw(404)
|
||||
return
|
||||
}
|
||||
return await next()
|
||||
} catch {
|
||||
ctx.throw(404)
|
||||
}
|
||||
})
|
||||
|
||||
const providers: ProviderMeta[] = []
|
||||
const routers = { admin: adminRouter, public: publicRouter }
|
||||
loadTextControllers(routers, providers)
|
||||
loadEmailControllers(routers, providers)
|
||||
loadWebhookControllers(routers, providers)
|
||||
loadPushControllers(routers, providers)
|
||||
loadAnalyticsControllers(routers, providers)
|
||||
|
||||
loadTextControllers(router, providers)
|
||||
loadEmailControllers(router, providers)
|
||||
loadWebhookControllers(router, providers)
|
||||
loadPushControllers(router, providers)
|
||||
loadAnalyticsControllers(router, providers)
|
||||
|
||||
router.get('/', async ctx => {
|
||||
adminRouter.get('/', async ctx => {
|
||||
const params = extractQueryParams(ctx.query, searchParamsSchema)
|
||||
ctx.body = await pagedProviders(params, ctx.state.project.id)
|
||||
})
|
||||
|
||||
router.get('/all', async ctx => {
|
||||
adminRouter.get('/all', async ctx => {
|
||||
ctx.body = await allProviders(ctx.state.project.id)
|
||||
})
|
||||
|
||||
router.get('/meta', async ctx => {
|
||||
adminRouter.get('/meta', async ctx => {
|
||||
ctx.body = providers
|
||||
})
|
||||
|
||||
export default router
|
||||
export { adminRouter, publicRouter }
|
||||
|
|
|
@ -1,21 +1,18 @@
|
|||
import App from '../app'
|
||||
import Provider, { ProviderMap, ProviderParams, ExternalProviderParams } from './Provider'
|
||||
|
||||
export const getProvider = async (id: number, projectId: number) => {
|
||||
return await Provider.find(id, qb => qb.where('project_id', projectId))
|
||||
export const getProvider = async (id: number, projectId?: number) => {
|
||||
return await Provider.find(id, qb => projectId ? qb.where('project_id', projectId) : qb)
|
||||
}
|
||||
|
||||
export const loadProvider = async <T extends Provider>(id: number, projectId: number, mapper: ProviderMap<T>, app = App.main) => {
|
||||
export const loadProvider = async <T extends Provider>(id: number, mapper: ProviderMap<T>, projectId?: number, app = App.main) => {
|
||||
|
||||
// Check if value is cached in memory
|
||||
const cache = app.get<T>(Provider.cacheKey.internal(id))
|
||||
if (cache) return cache
|
||||
|
||||
// If not, fetch from DB
|
||||
const record = await Provider.table()
|
||||
.where('project_id', projectId)
|
||||
.where('id', id)
|
||||
.first()
|
||||
const record = await Provider.find(id, qb => projectId ? qb.where('project_id', projectId) : qb, app.db)
|
||||
if (!record) return
|
||||
|
||||
// Map to appropriate type, cache and return
|
||||
|
@ -44,24 +41,6 @@ export const loadDefaultProvider = async <T extends Provider>(group: string, pro
|
|||
return mappedValue
|
||||
}
|
||||
|
||||
export const getProviderByExternalId = async <T extends Provider>(externalId: string, mapper: ProviderMap<T>, app = App.main): Promise<T | undefined> => {
|
||||
|
||||
// Check if value is cached in memory
|
||||
const cache = app.get<T>(Provider.cacheKey.external(externalId))
|
||||
if (cache) return cache
|
||||
|
||||
// If not, fetch from DB
|
||||
const record = await Provider.table()
|
||||
.where('external_id', externalId)
|
||||
.first()
|
||||
if (!record) return
|
||||
|
||||
// Map to appropriate type, cache and return
|
||||
const mappedValue = mapper(record)
|
||||
cacheProvider(mappedValue)
|
||||
return mappedValue
|
||||
}
|
||||
|
||||
export const createProvider = async (projectId: number, params: ProviderParams) => {
|
||||
return await Provider.insertAndFetch({
|
||||
...params,
|
||||
|
|
|
@ -2,7 +2,7 @@ import Router from '@koa/router'
|
|||
import { ProjectState } from '../auth/AuthMiddleware'
|
||||
import { SearchParams } from '../core/searchParams'
|
||||
import { JSONSchemaType, validate } from '../core/validate'
|
||||
import Provider, { ExternalProviderParams, ProviderGroup } from './Provider'
|
||||
import Provider, { ExternalProviderParams, ProviderControllers, ProviderGroup, ProviderMeta } from './Provider'
|
||||
import { createProvider, getProvider, updateProvider } from './ProviderRepository'
|
||||
|
||||
export const allProviders = async (projectId: number) => {
|
||||
|
@ -17,6 +17,30 @@ export const pagedProviders = async (params: SearchParams, projectId: number) =>
|
|||
)
|
||||
}
|
||||
|
||||
export const loadControllers = <T extends Record<string, any>>(typeMap: T, channel: string) => {
|
||||
return async (routers: ProviderControllers, providers: ProviderMeta[]) => {
|
||||
for (const type of Object.values(typeMap)) {
|
||||
const { admin, public: publicRouter }: ProviderControllers = type.controllers()
|
||||
routers.admin.use(
|
||||
admin.routes(),
|
||||
admin.allowedMethods(),
|
||||
)
|
||||
if (routers.public && publicRouter) {
|
||||
routers.public.use(
|
||||
publicRouter.routes(),
|
||||
publicRouter.allowedMethods(),
|
||||
)
|
||||
}
|
||||
providers.push({
|
||||
...type.meta,
|
||||
type: type.namespace,
|
||||
channel,
|
||||
schema: type.schema,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const createController = <T extends ExternalProviderParams>(group: ProviderGroup, type: string, schema: JSONSchemaType<T>, externalKey?: (payload: T) => string): Router => {
|
||||
const router = new Router<
|
||||
ProjectState & { provider?: Provider }
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import Router from '@koa/router'
|
||||
import { Analytics as Segment } from '@segment/analytics-node'
|
||||
import { ProviderParams, ProviderSchema } from '../Provider'
|
||||
import { ProviderControllers, ProviderParams, ProviderSchema } from '../Provider'
|
||||
import { AnalyticsTypeConfig } from './Analytics'
|
||||
import { AnalyticsProvider, AnalyticsUserEvent } from './AnalyticsProvider'
|
||||
import { createController } from '../ProviderService'
|
||||
|
@ -52,7 +51,7 @@ export default class SegmentAnalyticsProvider extends AnalyticsProvider {
|
|||
})
|
||||
}
|
||||
|
||||
static controllers(): Router {
|
||||
return createController('analytics', this.namespace, this.schema)
|
||||
static controllers(): ProviderControllers {
|
||||
return { admin: createController('analytics', this.namespace, this.schema) }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import Router from '@koa/router'
|
||||
import { ProviderMeta } from '../Provider'
|
||||
import { loadDefaultProvider } from '../ProviderRepository'
|
||||
import { loadControllers } from '../ProviderService'
|
||||
import Analytics from './Analytics'
|
||||
import { AnalyticsProvider, AnalyticsProviderName } from './AnalyticsProvider'
|
||||
import SegmentAnalyticsProvider from './SegmentProvider'
|
||||
|
@ -18,18 +17,4 @@ export const loadAnalytics = async (projectId: number): Promise<Analytics> => {
|
|||
return new Analytics(provider)
|
||||
}
|
||||
|
||||
export const loadAnalyticsControllers = async (router: Router, providers: ProviderMeta[]) => {
|
||||
for (const type of Object.values(typeMap)) {
|
||||
const controllers = type.controllers()
|
||||
router.use(
|
||||
controllers.routes(),
|
||||
controllers.allowedMethods(),
|
||||
)
|
||||
providers.push({
|
||||
...type.meta,
|
||||
type: type.namespace,
|
||||
channel: 'analytics',
|
||||
schema: type.schema,
|
||||
})
|
||||
}
|
||||
}
|
||||
export const loadAnalyticsControllers = loadControllers(typeMap, 'analytics')
|
||||
|
|
|
@ -7,4 +7,5 @@ export interface Email {
|
|||
subject: string
|
||||
text: string
|
||||
html: string
|
||||
headers?: Record<string, any>
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { Variables, Wrap } from '../../render'
|
||||
import { EmailTemplate } from '../../render/Template'
|
||||
import { Email } from './Email'
|
||||
import EmailProvider from './EmailProvider'
|
||||
|
||||
export default class EmailChannel {
|
||||
|
@ -19,7 +20,7 @@ export default class EmailChannel {
|
|||
// TODO: Explore caching the Handlebars template
|
||||
// before passing in variables for better performance
|
||||
const compiled = template.compile(variables)
|
||||
const email = {
|
||||
const email: Email = {
|
||||
...compiled,
|
||||
to: variables.user.email,
|
||||
html: Wrap({
|
||||
|
@ -27,6 +28,10 @@ export default class EmailChannel {
|
|||
preheader: compiled.preheader,
|
||||
variables,
|
||||
}), // Add link and open tracking
|
||||
headers: {
|
||||
'X-Campaign-Id': variables.context.campaign_id,
|
||||
'X-Subscription-Id': variables.context.subscription_id,
|
||||
},
|
||||
}
|
||||
await this.provider.send(email)
|
||||
}
|
||||
|
|
|
@ -19,8 +19,9 @@ export default class EmailJob extends Job {
|
|||
|
||||
const { campaign, template, user, project, event } = data
|
||||
const context = {
|
||||
campaign_id: campaign?.id,
|
||||
template_id: template?.id,
|
||||
campaign_id: campaign.id,
|
||||
template_id: template.id,
|
||||
subscription_id: campaign.subscription_id,
|
||||
}
|
||||
|
||||
// Send and render email
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import Router from '@koa/router'
|
||||
import { logger } from '../../config/logger'
|
||||
import { randomInt, sleep } from '../../utilities'
|
||||
import { ExternalProviderParams, ProviderSchema } from '../Provider'
|
||||
import { ExternalProviderParams, ProviderControllers, ProviderSchema } from '../Provider'
|
||||
import { createController } from '../ProviderService'
|
||||
import { Email } from './Email'
|
||||
import EmailProvider from './EmailProvider'
|
||||
|
@ -31,7 +30,7 @@ export default class LoggerEmailProvider extends EmailProvider {
|
|||
return true
|
||||
}
|
||||
|
||||
static controllers(): Router {
|
||||
return createController('email', this.namespace, this.schema)
|
||||
static controllers(): ProviderControllers {
|
||||
return { admin: createController('email', this.namespace, this.schema) }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,8 +3,13 @@ import aws = require('@aws-sdk/client-ses')
|
|||
import { AWSConfig } from '../../core/aws'
|
||||
import EmailProvider from './EmailProvider'
|
||||
import Router = require('@koa/router')
|
||||
import { ExternalProviderParams, ProviderSchema } from '../Provider'
|
||||
import Provider, { ExternalProviderParams, ProviderControllers, ProviderSchema } from '../Provider'
|
||||
import { createController } from '../ProviderService'
|
||||
import { createEvent } from '../../users/UserEventRepository'
|
||||
import { secondsAgo } from '../../utilities'
|
||||
import { getUserFromEmail } from '../../users/UserRepository'
|
||||
import { unsubscribe } from '../../subscriptions/SubscriptionService'
|
||||
import { RequestError } from '../../core/errors'
|
||||
|
||||
interface SESDataParams {
|
||||
config: AWSConfig
|
||||
|
@ -58,7 +63,56 @@ export default class SESEmailProvider extends EmailProvider {
|
|||
})
|
||||
}
|
||||
|
||||
static controllers(): Router {
|
||||
return createController('email', this.namespace, this.schema)
|
||||
static controllers(): ProviderControllers {
|
||||
const admin = createController('email', this.namespace, this.schema)
|
||||
|
||||
const router = new Router<{ provider: Provider }>()
|
||||
router.post(`/${this.namespace}`, async ctx => {
|
||||
const { Type, Message, SubscribeURL, Timestamp } = ctx.request.body
|
||||
const timestamp = Date.parse(Timestamp)
|
||||
if (!Type || secondsAgo(timestamp) > 30) throw new RequestError('Unsupported SES Body')
|
||||
|
||||
ctx.status = 204
|
||||
|
||||
// If we are getting an SNS topic confirmation, ping back
|
||||
if (Type === 'SubscriptionConfirmation' && SubscribeURL) {
|
||||
await fetch(SubscribeURL)
|
||||
} else if (Type === 'Notification' && Message) {
|
||||
await this.rejection(ctx.state.provider.project_id, Message)
|
||||
}
|
||||
})
|
||||
|
||||
return { admin, public: router }
|
||||
}
|
||||
|
||||
static async rejection(projectId: number, message: string) {
|
||||
const getHeader = (
|
||||
headers: Array<{ name: string, value: string }>,
|
||||
key: string,
|
||||
) => (headers ?? []).find((item) => item.name === key)?.value
|
||||
|
||||
const json = JSON.parse(message) as Record<string, any>
|
||||
const { notificationType, mail: { destination, headers } } = json
|
||||
const email: string | undefined = destination[0]
|
||||
const subscriptionId = getHeader(headers, 'X-Subscription-Id')
|
||||
const campaignId = getHeader(headers, 'X-Campaign-Id')
|
||||
|
||||
if (!email || !subscriptionId) return
|
||||
|
||||
const user = await getUserFromEmail(projectId, email)
|
||||
if (!user) return
|
||||
|
||||
const name = notificationType === 'Bounce' ? 'bounce' : 'complaint'
|
||||
await createEvent(user, {
|
||||
name,
|
||||
data: {
|
||||
subscription_id: subscriptionId,
|
||||
campaign_id: campaignId,
|
||||
},
|
||||
})
|
||||
|
||||
if (name === 'bounce' && subscriptionId) {
|
||||
await unsubscribe(user.id, parseInt(subscriptionId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import Router from '@koa/router'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { ExternalProviderParams, ProviderSchema } from '../Provider'
|
||||
import { ExternalProviderParams, ProviderControllers, ProviderSchema } from '../Provider'
|
||||
import { createController } from '../ProviderService'
|
||||
import EmailProvider from './EmailProvider'
|
||||
|
||||
|
@ -55,7 +54,7 @@ export default class SMTPEmailProvider extends EmailProvider {
|
|||
})
|
||||
}
|
||||
|
||||
static controllers(): Router {
|
||||
return createController('email', this.namespace, this.schema)
|
||||
static controllers(): ProviderControllers {
|
||||
return { admin: createController('email', this.namespace, this.schema) }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import Router from '@koa/router'
|
||||
import { ProviderMeta } from '../Provider'
|
||||
import { loadProvider } from '../ProviderRepository'
|
||||
import { loadControllers } from '../ProviderService'
|
||||
import EmailChannel from './EmailChannel'
|
||||
import EmailProvider, { EmailProviderName } from './EmailProvider'
|
||||
import LoggerEmailProvider from './LoggerEmailProvider'
|
||||
|
@ -18,23 +17,9 @@ export const providerMap = (record: { type: EmailProviderName }): EmailProvider
|
|||
}
|
||||
|
||||
export const loadEmailChannel = async (providerId: number, projectId: number): Promise<EmailChannel | undefined> => {
|
||||
const provider = await loadProvider(providerId, projectId, providerMap)
|
||||
const provider = await loadProvider(providerId, providerMap, projectId)
|
||||
if (!provider) return
|
||||
return new EmailChannel(provider)
|
||||
}
|
||||
|
||||
export const loadEmailControllers = async (router: Router, providers: ProviderMeta[]) => {
|
||||
for (const type of Object.values(typeMap)) {
|
||||
const controllers = type.controllers()
|
||||
router.use(
|
||||
controllers.routes(),
|
||||
controllers.allowedMethods(),
|
||||
)
|
||||
providers.push({
|
||||
...type.meta,
|
||||
type: type.namespace,
|
||||
channel: 'email',
|
||||
schema: type.schema,
|
||||
})
|
||||
}
|
||||
}
|
||||
export const loadEmailControllers = loadControllers(typeMap, 'email')
|
||||
|
|
|
@ -2,8 +2,7 @@ import { PushProvider } from './PushProvider'
|
|||
import PushNotifications from 'node-pushnotifications'
|
||||
import { Push, PushResponse } from './Push'
|
||||
import PushError from './PushError'
|
||||
import Router from '@koa/router'
|
||||
import { ExternalProviderParams, ProviderSchema } from '../Provider'
|
||||
import { ExternalProviderParams, ProviderControllers, ProviderSchema } from '../Provider'
|
||||
import { createController } from '../ProviderService'
|
||||
|
||||
interface APNParams {
|
||||
|
@ -111,7 +110,7 @@ export default class LocalPushProvider extends PushProvider {
|
|||
}
|
||||
}
|
||||
|
||||
static controllers(): Router {
|
||||
return createController('push', this.namespace, this.schema)
|
||||
static controllers(): ProviderControllers {
|
||||
return { admin: createController('push', this.namespace, this.schema) }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import Router from '@koa/router'
|
||||
import { logger } from '../../config/logger'
|
||||
import { randomInt, sleep } from '../../utilities'
|
||||
import { ExternalProviderParams, ProviderSchema } from '../Provider'
|
||||
import { ExternalProviderParams, ProviderControllers, ProviderSchema } from '../Provider'
|
||||
import { createController } from '../ProviderService'
|
||||
import { Push, PushResponse } from './Push'
|
||||
import { PushProvider } from './PushProvider'
|
||||
|
@ -31,7 +30,7 @@ export default class LoggerPushProvider extends PushProvider {
|
|||
}
|
||||
}
|
||||
|
||||
static controllers(): Router {
|
||||
return createController('email', 'push', this.schema)
|
||||
static controllers(): ProviderControllers {
|
||||
return { admin: createController('email', 'push', this.schema) }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,8 +21,9 @@ export default class PushJob extends Job {
|
|||
|
||||
const { campaign, template, user, project, event } = data
|
||||
const context = {
|
||||
campaign_id: campaign?.id,
|
||||
template_id: template?.id,
|
||||
campaign_id: campaign.id,
|
||||
template_id: template.id,
|
||||
subscription_id: campaign.subscription_id,
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import Router from '@koa/router'
|
||||
import { ProviderMeta } from '../Provider'
|
||||
import { loadProvider } from '../ProviderRepository'
|
||||
import { loadControllers } from '../ProviderService'
|
||||
import LocalPushProvider from './LocalPushProvider'
|
||||
import LoggerPushProvider from './LoggerPushProvider'
|
||||
import PushChannel from './PushChannel'
|
||||
|
@ -16,23 +15,9 @@ export const providerMap = (record: { type: PushProviderName }): PushProvider =>
|
|||
}
|
||||
|
||||
export const loadPushChannel = async (providerId: number, projectId: number): Promise<PushChannel | undefined> => {
|
||||
const provider = await loadProvider(providerId, projectId, providerMap)
|
||||
const provider = await loadProvider(providerId, providerMap, projectId)
|
||||
if (!provider) return
|
||||
return new PushChannel(provider)
|
||||
}
|
||||
|
||||
export const loadPushControllers = async (router: Router, providers: ProviderMeta[]) => {
|
||||
for (const type of Object.values(typeMap)) {
|
||||
const controllers = type.controllers()
|
||||
router.use(
|
||||
controllers.routes(),
|
||||
controllers.allowedMethods(),
|
||||
)
|
||||
providers.push({
|
||||
...type.meta,
|
||||
type: type.namespace,
|
||||
channel: 'push',
|
||||
schema: type.schema,
|
||||
})
|
||||
}
|
||||
}
|
||||
export const loadPushControllers = loadControllers(typeMap, 'push')
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import Router from '@koa/router'
|
||||
import { logger } from '../../config/logger'
|
||||
import { randomInt, sleep } from '../../utilities'
|
||||
import { ProviderParams, ProviderSchema } from '../Provider'
|
||||
import { ProviderControllers, ProviderParams, ProviderSchema } from '../Provider'
|
||||
import { createController } from '../ProviderService'
|
||||
import { InboundTextMessage, TextMessage, TextResponse } from './TextMessage'
|
||||
import { TextProvider } from './TextProvider'
|
||||
|
@ -40,7 +39,7 @@ export default class LoggerTextProvider extends TextProvider {
|
|||
}
|
||||
}
|
||||
|
||||
static controllers(): Router {
|
||||
return createController('text', this.namespace, this.schema)
|
||||
static controllers(): ProviderControllers {
|
||||
return { admin: createController('text', this.namespace, this.schema) }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import { InboundTextMessage, TextMessage, TextResponse } from './TextMessage'
|
||||
import TextError from './TextError'
|
||||
import { TextProvider } from './TextProvider'
|
||||
import { ProviderParams, ProviderSchema } from '../Provider'
|
||||
import Router from '@koa/router'
|
||||
import { ProviderControllers, ProviderParams, ProviderSchema } from '../Provider'
|
||||
import { createController } from '../ProviderService'
|
||||
|
||||
interface NexmoDataParams {
|
||||
|
@ -83,7 +82,8 @@ export default class NexmoTextProvider extends TextProvider {
|
|||
}
|
||||
}
|
||||
|
||||
static controllers(): Router {
|
||||
return createController('text', this.namespace, this.schema, (payload) => payload.data.phone_number)
|
||||
static controllers(): ProviderControllers {
|
||||
const admin = createController('text', this.namespace, this.schema)
|
||||
return { admin, public: this.unsubscribe(this.namespace) }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import Router from '@koa/router'
|
||||
import { ExternalProviderParams, ProviderSchema } from '../Provider'
|
||||
import { ExternalProviderParams, ProviderControllers, ProviderSchema } from '../Provider'
|
||||
import { createController } from '../ProviderService'
|
||||
import { InboundTextMessage, TextMessage, TextResponse } from './TextMessage'
|
||||
import { TextProvider } from './TextProvider'
|
||||
|
@ -77,7 +76,8 @@ export default class PlivoTextProvider extends TextProvider {
|
|||
}
|
||||
}
|
||||
|
||||
static controllers(): Router {
|
||||
return createController('text', this.namespace, this.schema, (payload) => payload.data.phone_number)
|
||||
static controllers(): ProviderControllers {
|
||||
const admin = createController('text', this.namespace, this.schema)
|
||||
return { admin, public: this.unsubscribe(this.namespace) }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,11 +18,7 @@ export default class TextJob extends Job {
|
|||
const data = await loadSendJob<TextTemplate>(trigger)
|
||||
if (!data) return
|
||||
|
||||
const { campaign, template, user, project, event } = data
|
||||
const context = {
|
||||
campaign_id: campaign?.id,
|
||||
template_id: template?.id,
|
||||
}
|
||||
const { campaign, template, user, project, event, context } = data
|
||||
|
||||
// Send and render text
|
||||
const channel = await loadTextChannel(campaign.provider_id, project.id)
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
import Router from '@koa/router'
|
||||
import { loadTextChannel } from '.'
|
||||
import { unsubscribeSms } from '../../subscriptions/SubscriptionService'
|
||||
import Provider from '../Provider'
|
||||
import { InboundTextMessage, TextMessage, TextResponse } from './TextMessage'
|
||||
|
||||
|
@ -6,4 +9,18 @@ export type TextProviderName = 'nexmo' | 'plivo' | 'twilio' | 'logger'
|
|||
export abstract class TextProvider extends Provider {
|
||||
abstract send(message: TextMessage): Promise<TextResponse>
|
||||
abstract parseInbound(inbound: any): InboundTextMessage
|
||||
|
||||
static unsubscribe(namespace: string) {
|
||||
const router = new Router<{ provider: Provider }>()
|
||||
router.post(`/${namespace}/unsubscribe`, async ctx => {
|
||||
const channel = await loadTextChannel(ctx.state.provider.id)
|
||||
if (!channel) return
|
||||
|
||||
// Always return with positive status code
|
||||
ctx.status = 204
|
||||
|
||||
await unsubscribeSms(channel.provider, ctx.request.body)
|
||||
})
|
||||
return router
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import Router from '@koa/router'
|
||||
import { ExternalProviderParams, ProviderSchema } from '../Provider'
|
||||
import { ExternalProviderParams, ProviderControllers, ProviderSchema } from '../Provider'
|
||||
import { createController } from '../ProviderService'
|
||||
import { InboundTextMessage, TextMessage, TextResponse } from './TextMessage'
|
||||
import { TextProvider } from './TextProvider'
|
||||
|
@ -82,7 +81,8 @@ export default class TwilioTextProvider extends TextProvider {
|
|||
}
|
||||
}
|
||||
|
||||
static controllers(): Router {
|
||||
return createController('text', this.namespace, this.schema, (payload) => payload.data.phone_number)
|
||||
static controllers(): ProviderControllers {
|
||||
const admin = createController('text', this.namespace, this.schema)
|
||||
return { admin, public: this.unsubscribe(this.namespace) }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import Router from '@koa/router'
|
||||
import { ProviderMeta } from '../Provider'
|
||||
import { getProviderByExternalId, loadProvider } from '../ProviderRepository'
|
||||
import { loadProvider } from '../ProviderRepository'
|
||||
import { loadControllers } from '../ProviderService'
|
||||
import LoggerTextProvider from './LoggerTextProvider'
|
||||
import NexmoTextProvider from './NexmoTextProvider'
|
||||
import PlivoTextProvider from './PlivoTextProvider'
|
||||
|
@ -19,30 +18,10 @@ export const providerMap = (record: { type: TextProviderName }): TextProvider =>
|
|||
return typeMap[record.type].fromJson(record)
|
||||
}
|
||||
|
||||
export const loadTextChannel = async (providerId: number, projectId: number): Promise<TextChannel | undefined> => {
|
||||
const provider = await loadProvider(providerId, projectId, providerMap)
|
||||
export const loadTextChannel = async (providerId: number, projectId?: number): Promise<TextChannel | undefined> => {
|
||||
const provider = await loadProvider(providerId, providerMap, projectId)
|
||||
if (!provider) return
|
||||
return new TextChannel(provider)
|
||||
}
|
||||
|
||||
export const loadTextChannelInbound = async (inboundNumber: string): Promise<TextChannel | undefined> => {
|
||||
const provider = await getProviderByExternalId(inboundNumber, providerMap)
|
||||
if (!provider) return
|
||||
return new TextChannel(provider)
|
||||
}
|
||||
|
||||
export const loadTextControllers = async (router: Router, providers: ProviderMeta[]) => {
|
||||
for (const type of Object.values(typeMap)) {
|
||||
const controllers = type.controllers()
|
||||
router.use(
|
||||
controllers.routes(),
|
||||
controllers.allowedMethods(),
|
||||
)
|
||||
providers.push({
|
||||
...type.meta,
|
||||
type: type.namespace,
|
||||
channel: 'text',
|
||||
schema: type.schema,
|
||||
})
|
||||
}
|
||||
}
|
||||
export const loadTextControllers = loadControllers(typeMap, 'text')
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import Router from '@koa/router'
|
||||
import { ProviderParams, ProviderSchema } from '../Provider'
|
||||
import { ProviderControllers, ProviderParams, ProviderSchema } from '../Provider'
|
||||
import { createController } from '../ProviderService'
|
||||
import { Webhook, WebhookResponse } from './Webhook'
|
||||
import { WebhookProvider } from './WebhookProvider'
|
||||
|
@ -37,7 +36,7 @@ export default class LocalWebhookProvider extends WebhookProvider {
|
|||
}
|
||||
}
|
||||
|
||||
static controllers(): Router {
|
||||
return createController('webhook', this.namespace, this.schema)
|
||||
static controllers(): ProviderControllers {
|
||||
return { admin: createController('webhook', this.namespace, this.schema) }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import Router from '@koa/router'
|
||||
import { logger } from '../../config/logger'
|
||||
import { randomInt, sleep } from '../../utilities'
|
||||
import { ProviderParams, ProviderSchema } from '../Provider'
|
||||
import { ProviderControllers, ProviderParams, ProviderSchema } from '../Provider'
|
||||
import { createController } from '../ProviderService'
|
||||
import { Webhook, WebhookResponse } from './Webhook'
|
||||
import { WebhookProvider } from './WebhookProvider'
|
||||
|
@ -32,7 +31,7 @@ export default class LoggerWebhookProvider extends WebhookProvider {
|
|||
}
|
||||
}
|
||||
|
||||
static controllers(): Router {
|
||||
return createController('webhook', this.namespace, this.schema)
|
||||
static controllers(): ProviderControllers {
|
||||
return { admin: createController('webhook', this.namespace, this.schema) }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,11 +17,7 @@ export default class WebhookJob extends Job {
|
|||
const data = await loadSendJob<WebhookTemplate>(trigger)
|
||||
if (!data) return
|
||||
|
||||
const { campaign, template, user, project, event } = data
|
||||
const context = {
|
||||
campaign_id: campaign?.id,
|
||||
template_id: template?.id,
|
||||
}
|
||||
const { campaign, template, user, project, event, context } = data
|
||||
|
||||
// Send and render webhook
|
||||
const channel = await loadWebhookChannel(campaign.provider_id, project.id)
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import Router from '@koa/router'
|
||||
import { ProviderMeta } from '../Provider'
|
||||
import { loadProvider } from '../ProviderRepository'
|
||||
import { loadControllers } from '../ProviderService'
|
||||
import LocalWebhookProvider from './LocalWebhookProvider'
|
||||
import LoggerWebhookProvider from './LoggerWebhookProvider'
|
||||
import WebhookChannel from './WebhookChannel'
|
||||
|
@ -16,23 +15,9 @@ export const providerMap = (record: { type: WebhookProviderName }): WebhookProvi
|
|||
}
|
||||
|
||||
export const loadWebhookChannel = async (providerId: number, projectId: number): Promise<WebhookChannel | undefined> => {
|
||||
const provider = await loadProvider(providerId, projectId, providerMap)
|
||||
const provider = await loadProvider(providerId, providerMap, projectId)
|
||||
if (!provider) return
|
||||
return new WebhookChannel(provider)
|
||||
}
|
||||
|
||||
export const loadWebhookControllers = async (router: Router, providers: ProviderMeta[]) => {
|
||||
for (const type of Object.values(typeMap)) {
|
||||
const controllers = type.controllers()
|
||||
router.use(
|
||||
controllers.routes(),
|
||||
controllers.allowedMethods(),
|
||||
)
|
||||
providers.push({
|
||||
...type.meta,
|
||||
type: type.namespace,
|
||||
channel: 'webhook',
|
||||
schema: type.schema,
|
||||
})
|
||||
}
|
||||
}
|
||||
export const loadWebhookControllers = loadControllers(typeMap, 'webhook')
|
||||
|
|
|
@ -96,7 +96,7 @@ export class EmailTemplate extends Template {
|
|||
validate() {
|
||||
return isValid({
|
||||
type: 'object',
|
||||
required: ['from', 'subject', 'text', 'html'],
|
||||
required: ['from', 'subject', 'html'],
|
||||
properties: {
|
||||
from: { type: 'string' },
|
||||
subject: { type: 'string' },
|
||||
|
|
|
@ -9,10 +9,11 @@ import { unsubscribeEmailLink } from '../subscriptions/SubscriptionService'
|
|||
import { clickWrapHtml, openWrapHtml, preheaderWrapHtml } from './LinkService'
|
||||
import App from '../app'
|
||||
|
||||
export interface RenderContext {
|
||||
export type RenderContext = {
|
||||
template_id: number
|
||||
campaign_id: number
|
||||
}
|
||||
subscription_id: number
|
||||
} & Record<string, unknown>
|
||||
|
||||
export interface Variables {
|
||||
context: RenderContext
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
import Router from '@koa/router'
|
||||
import App from '../app'
|
||||
import { loadTextChannelInbound } from '../providers/text'
|
||||
import { RequestError } from '../core/errors'
|
||||
import { JSONSchemaType, validate } from '../core/validate'
|
||||
import Subscription, { SubscriptionParams } from './Subscription'
|
||||
import { createSubscription, getSubscription, pagedSubscriptions, unsubscribe, unsubscribeSms } from './SubscriptionService'
|
||||
import { createSubscription, getSubscription, pagedSubscriptions, unsubscribe } from './SubscriptionService'
|
||||
import SubscriptionError from './SubscriptionError'
|
||||
import { encodedLinkToParts } from '../render/LinkService'
|
||||
import { ProjectState } from '../auth/AuthMiddleware'
|
||||
|
@ -40,7 +39,6 @@ export const emailUnsubscribeSchema: JSONSchemaType<EmailUnsubscribeParams> = {
|
|||
},
|
||||
additionalProperties: false,
|
||||
}
|
||||
|
||||
publicRouter.post('/email', async ctx => {
|
||||
|
||||
const { user, campaign } = await encodedLinkToParts(ctx.URL)
|
||||
|
@ -52,19 +50,6 @@ publicRouter.post('/email', async ctx => {
|
|||
ctx.status = 204
|
||||
})
|
||||
|
||||
publicRouter.post('/sms', async ctx => {
|
||||
|
||||
// Always return with positive status code
|
||||
ctx.status = 204
|
||||
|
||||
// Match up to provider based on inbound number
|
||||
const to = ctx.request.body.To || ctx.request.body.to
|
||||
const channel = await loadTextChannelInbound(to)
|
||||
if (!channel) return
|
||||
|
||||
await unsubscribeSms(channel.provider, ctx.request.body)
|
||||
})
|
||||
|
||||
export { publicRouter }
|
||||
|
||||
/**
|
||||
|
|
|
@ -32,6 +32,13 @@ export const getUserFromPhone = async (projectId: number, phone: string): Promis
|
|||
)
|
||||
}
|
||||
|
||||
export const getUserFromEmail = async (projectId: number, email: string): Promise<User | undefined> => {
|
||||
return await User.first(
|
||||
qb => qb.where('email', email)
|
||||
.where('project_id', projectId),
|
||||
)
|
||||
}
|
||||
|
||||
export const pagedUsers = async (params: SearchParams, projectId: number) => {
|
||||
return await User.searchParams(
|
||||
params,
|
||||
|
|
|
@ -2,6 +2,7 @@ import { JSONSchemaType } from 'ajv'
|
|||
import { validate } from '../core/validate'
|
||||
import crypto from 'crypto'
|
||||
import Hashids from 'hashids'
|
||||
import { differenceInSeconds } from 'date-fns'
|
||||
|
||||
export const pluralize = (noun: string, count = 2, suffix = 's') => `${noun}${count !== 1 ? suffix : ''}`
|
||||
|
||||
|
@ -37,6 +38,10 @@ export const uuid = (): string => {
|
|||
return crypto.randomUUID()
|
||||
}
|
||||
|
||||
export const secondsAgo = (timestamp: Date | number) => {
|
||||
return differenceInSeconds(Date.now(), timestamp)
|
||||
}
|
||||
|
||||
const hashCharacters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'
|
||||
const hashMinimumLength = 10
|
||||
export const encodeHashid = function(value: number): string {
|
||||
|
|
|
@ -8,20 +8,17 @@ pagination_next: null
|
|||
custom_edit_url: null
|
||||
---
|
||||
|
||||
import Card from '@site/src/components/Card'
|
||||
import Cards from '@site/src/components/Cards'
|
||||
|
||||
# Documentation
|
||||
## What is Parcelvoy?
|
||||
Parcelvoy is an open sourced automated messaging and customer engagement tool for growth companies and enterprise alike. It combines the user experience you would expect in a Saas product with the flexibility of an open sourced tool.
|
||||
|
||||
## Discover
|
||||
|
||||
### Overview
|
||||
Start here to get up and running and get aquainted at a high level with Parcelvoy.
|
||||
|
||||
### How To
|
||||
Learn each component of Parcelvoy and how to do everything from create a campaign to generating a dynamic list.
|
||||
|
||||
### Integrations & Clients
|
||||
To make full use of Parcelvoy you'll want to integrate it with our list of providers and include it in your app.
|
||||
|
||||
### API
|
||||
We have an extensive list of available APIs to manage the platform or ingest data however you see fit. Documentation Coming soon.
|
||||
<Cards>
|
||||
<Card title="Overview" href="/overview/introduction">Start here to get up and running and get aquainted at a high level with Parcelvoy.</Card>
|
||||
<Card title="How To" href="/how-to">Learn each component of Parcelvoy and how to do everything from create a campaign to generating a dynamic list.</Card>
|
||||
<Card title="Integrations & Clients" href="/providers">To make full use of Parcelvoy you'll want to integrate it with our list of providers and include it in your app.</Card>
|
||||
<Card title="API" href="/api">We have an extensive list of available APIs to manage the platform or ingest data however you see fit. Documentation Coming soon.</Card>
|
||||
</Cards>
|
|
@ -1 +1,3 @@
|
|||
# Index for settings!
|
||||
# Settings
|
||||
Under the settings section you can configure all things related to a project.Most configuration settings are project specific to allow things to be more flexible. You could setup projects to be anything from testing/production environments, to represent a given client.
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"label": "Quick Start",
|
||||
"label": "Overview",
|
||||
"position": 1,
|
||||
"collapsible": false
|
||||
}
|
||||
}
|
||||
|
4
docs/docs/overview/introduction.md
Normal file
4
docs/docs/overview/introduction.md
Normal file
|
@ -0,0 +1,4 @@
|
|||
# Introduction
|
||||
Parcelvoy is an open sourced automated messaging and customer engagement tool for growth companies and enterprise alike. It combines the user experience you would expect in a SaaS product with the flexibility of an open sourced tool.
|
||||
|
||||
## Why?
|
1
docs/docs/overview/quick-start.md
Normal file
1
docs/docs/overview/quick-start.md
Normal file
|
@ -0,0 +1 @@
|
|||
# Quick Start
|
14
docs/docs/providers/index.md
Normal file
14
docs/docs/providers/index.md
Normal file
|
@ -0,0 +1,14 @@
|
|||
---
|
||||
title: Providers
|
||||
sidebar_position: 1
|
||||
pagination_prev: null
|
||||
pagination_next: null
|
||||
custom_edit_url: null
|
||||
---
|
||||
|
||||
import Providers from '@site/src/components/Providers'
|
||||
|
||||
# Providers
|
||||
Providers are the integration building blocks that let Parcelvoy do things. Currently they include senders (like Twilio for SMS) and analytics (like Segment)
|
||||
|
||||
<Providers />
|
|
@ -1,4 +0,0 @@
|
|||
# Nexmo
|
||||
## Setup
|
||||
## Outbound
|
||||
## Inbound
|
|
@ -1,6 +1,55 @@
|
|||
# SES
|
||||
Amazon SES is quite possibly the most cost effective email service available. If you are sending high volumes of emails it is highly recommended, but it does require some extra setup.
|
||||
## Setup
|
||||
### Requesting Approval
|
||||
## Inbound
|
||||
## Outbound
|
||||
|
||||
### Requesting Approval
|
||||
Once you are ready to use SES you must request approval to send emails in production. In general it is recommended you try and request access earlier than later as you may be denied. The process usually does not take longer than a day, but providing lots of context is important.
|
||||
|
||||
To request access, do the following:
|
||||
1. Navigate to the AWS SES portal (https://console.aws.amazon.com/ses/home)
|
||||
2. On your account dashboard you should see an alert informing you that your account is in sandbox mode. Hit the `Request Production Access` button.
|
||||
3. Fill out the form with the details of your product and submit. The more details the better as it will improve your chances of being approved.
|
||||
|
||||
## Outbound
|
||||
### Create Verified Identity
|
||||
1. From the SES portal, navigate to `Verified Identities`
|
||||
2. Click `Create Identity`
|
||||
3. In general for Parcelvoy you will want the flexibility of sending emails from any address, so we recommend picking the `Domain` entity type.
|
||||
4. Next, enter the domain you want to use for sending emails. In general it is recommended that you use a subdomain to prevent your primary emails reputation score (which may house your company emails) being affected by any marketing or transaction emails you send.
|
||||
5. Follow the instructions on how to verify your domain as well as setup DKIM for email security.
|
||||
6. Hit create. Once created, SES will verify your domain to make sure it is setup correctly.
|
||||
|
||||
### Setup Integration
|
||||
1. Navigate to `IAM` in the AWS portal
|
||||
2. Go to `Users` and hit `Create User`
|
||||
3. Pick a random username for your user and hit `Next`
|
||||
4. If you have existing groups that cover the permissions necessary use that, otherwise select `Attach policies directly` under `Permission Options`
|
||||
6. Search for `AmazonSES` and select `AmazonSESFullAccess` from the list
|
||||
7. Hit `Next` and then `Create User`
|
||||
8. Navigate to your newly created user and then to the `Security Credentials` tab
|
||||
9. Under `Access Keys` hit `Create Access Key`
|
||||
10. From the provided best practices list, pick `Application running outside AWS` and hit `Next` then `Create Access Key`
|
||||
11. Save the provided access key and secret access key
|
||||
12. Open a new window and go to your Parcelvoy project settings
|
||||
13. Navigate to `Integrations` and click the `Add Integration` button.
|
||||
14. Pick `Amazon SES` from the list of integrations and enter the `Access Key Id` (access key) and `Secret Access Key` in the provided fields.
|
||||
15. To determine your region, navigate back to SES and look at the URL bar. The first part before `console.aws.amazon.com` is your region (i.e. `us-east-1`). Enter the region in the appropriate field.
|
||||
16. Hit save to create the provider.
|
||||
|
||||
## Inbound
|
||||
Email sending is not hte only important part, you also need to keep track of things like email opens, clicks, unsubscribes, bounces and complaints. Parcelvoy automatically takes care of opens, clicks and unsubscribes for you, but bounces and complaints require notifications from SES.
|
||||
|
||||
To setup inbound notifications, do the following:
|
||||
1. Open the [https://console.aws.amazon.com/sns/home](Amazon SNS console) and choose `Topics`.
|
||||
2. On the Topics page, choose Create topic.
|
||||
3. In the `Details` section of the Create topic page, choose Standard for type and provider a name.
|
||||
4. Choose `Create topic`
|
||||
5. From the Topic details of the topic that you created, choose `Create subscription`
|
||||
6. For Protocol, select `HTTPS` and enter the Parcelvoy SES unsubscribe URL for your provider (which can be found on the provider details screen)
|
||||
7. Hit save
|
||||
8. Navigate to the [https://console.aws.amazon.com/ses/home]SES console) and choose `Verified identities`
|
||||
9. Select your previously created identity and go to the `Notifications` tab.
|
||||
10. Disable Email feedback forwarding
|
||||
11. Under Feedback notifications, hit `Edit`
|
||||
12. For `Bounce feedback` and `Complaint feedback` select the SNS topic you previously created and check the `Include original email headers` checkboxes.
|
||||
13. Hit save changes and you are all set!
|
|
@ -1,24 +1,37 @@
|
|||
# Twilio
|
||||
## Setup
|
||||
Start by creating a new account at [https://twilio.com](https://twilio.com).
|
||||
|
||||
After you've created an account, hit the `Account` button in the top right hand corner of the Twilio dashboard and navigate to the `General Settings -> Keys & Credentials -> API keys & tokens`. Under `Auth Tokens` there should be two sets of values, live credentials and test credentials. For Parcelvoy, you need live credentials.
|
||||
|
||||
Open a new window and go to your Parcelvoy project settings. Navigate to `Integrations` and click the `Add Integration` button, followed by picking Twilio from the list. Enter the `Auth Token` and `Account SID` from the Twilio window. Next, we will purchase a phone number.
|
||||
Start by creating a new account at [https://twilio.com](https://twilio.com). Once your account is created, the following steps will get your account linked to Parcelvoy
|
||||
|
||||
## Outbound
|
||||
All you need for outbound messages is a phone number that supports SMS.
|
||||
To purchase a new phone number, go to `Develop -> Phone Numbers -> Buy a Number`. From here, you can pick the search criteria you care about for a number. In order for Parcelvoy to work, just make sure it has SMS listed as a capability. You will not be able to send messages without it.
|
||||
|
||||
After you've purchased the number, enter it in the configuration on your Parcelvoy provider and hit save to create.
|
||||
If you already have a phone number, jump to step four.
|
||||
1. Go to `Develop -> Phone Numbers -> Buy a Number`
|
||||
2. From here, you can pick the search criteria you care about for a number. Just make sure the number selected supports SMS (Parcelvoy will not work without it)
|
||||
3. Purchase the number and copy it down.
|
||||
4. Next, hit the `Account` button in the top right hand corner of the Twilio dashboard and navigate to the `General Settings -> Keys & Credentials -> API keys & tokens`.
|
||||
5. Under `Auth Tokens` there should be two sets of values, live credentials and test credentials. For Parcelvoy, you need live credentials, copy them.
|
||||
6. Open a new window and go to your Parcelvoy project settings
|
||||
7. Navigate to `Integrations` and click the `Add Integration` button.
|
||||
8. Pick Twilio from the list of integrations and enter the `Auth Token`, `Account SID` and `Phone Number` from Twilio.
|
||||
9. Hit save to create the provider.
|
||||
|
||||
You are now setup to send SMS messages using Twilio. There is one more step however to make it fully functioning and that is to setup inbound messages so that Parcelvoy is notified of unsubscribes.
|
||||
|
||||
## Inbound
|
||||
By default Twilio automatically manages [opt-outs (unsubscribes)](https://support.twilio.com/hc/en-us/articles/360034798533-Getting-Started-with-Advanced-Opt-Out-for-Messaging-Services), you just have to listen for the inbound webhook to then register that event in Parcelvoy.
|
||||
|
||||
To setup inbound SMS for Twilio, go to `Develop -> Phone Numbers -> Manage -> Active Numbers` and pick the phone number you are using internally. From there scroll down to the `Messaging` section. On the enter `A Message Comes In` set the type to `Webhook`, the method to `HTTP POST` and then the URL to the following:
|
||||
To setup inbound SMS for Twilio, do the following:
|
||||
1. In Twilip, navigate to `Develop -> Phone Numbers -> Manage -> Active Numbers`.
|
||||
2. Pick the phone number you are using internally.
|
||||
3. Scroll down to the `Messaging` section.
|
||||
4. On the line item `A Message Comes In` set the type to `Webhook`, the method to `HTTP POST` and then the URL to the following:
|
||||
|
||||
```
|
||||
https://yourdomain.com/api/unsubscribe/sms
|
||||
```
|
||||
|
||||
Where `yourdomain.com` is replaced with whatever domain you are running Parcelvoy under.
|
||||
5. Save the values.
|
||||
|
||||
Inbound Twilio notifications are now configured and unsubscribe events will register as Parcelvoy user events.
|
||||
|
|
8
docs/docs/providers/vonage.md
Normal file
8
docs/docs/providers/vonage.md
Normal file
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
id: vonage
|
||||
---
|
||||
|
||||
# Vonage (Nexmo)
|
||||
## Setup
|
||||
## Outbound
|
||||
## Inbound
|
|
@ -61,6 +61,7 @@ const config = {
|
|||
logo: {
|
||||
alt: 'Parcelvoy',
|
||||
src: 'img/parcelvoy.svg',
|
||||
srcDark: 'img/parcelvoy-light.svg',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
|
|
21
docs/src/components/Card.css
Normal file
21
docs/src/components/Card.css
Normal file
|
@ -0,0 +1,21 @@
|
|||
.card {
|
||||
display: flex;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-grey);
|
||||
padding: 20px;
|
||||
line-height: 1.2;
|
||||
box-shadow: none;
|
||||
flex-direction: row;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
a.card:hover {
|
||||
cursor: pointer;
|
||||
border-color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.card img {
|
||||
border-radius: 8px;
|
||||
}
|
14
docs/src/components/Card.js
Normal file
14
docs/src/components/Card.js
Normal file
|
@ -0,0 +1,14 @@
|
|||
import React from 'react'
|
||||
import './Card.css'
|
||||
|
||||
export default function Card({ image, title, href, children }) {
|
||||
return (
|
||||
<a href={href} className="card">
|
||||
<img src={image} />
|
||||
<div className="card-content">
|
||||
<h3 style={{ margin: '0 0 5px 0', padding: '0' }}>{title}</h3>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
14
docs/src/components/Cards.js
Normal file
14
docs/src/components/Cards.js
Normal file
|
@ -0,0 +1,14 @@
|
|||
import React from 'react'
|
||||
|
||||
export default function Cards({ children }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: '20px'
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
21
docs/src/components/Providers.js
Normal file
21
docs/src/components/Providers.js
Normal file
|
@ -0,0 +1,21 @@
|
|||
import React from 'react'
|
||||
import { useDocsSidebar } from '@docusaurus/theme-common/internal'
|
||||
import Card from './Card'
|
||||
import Cards from './Cards'
|
||||
|
||||
export default function Providers() {
|
||||
const sidebar = useDocsSidebar()
|
||||
const items = sidebar.items.filter((item) => item.label === 'Providers')[0].items
|
||||
|
||||
return (
|
||||
<div className="providers">
|
||||
<Cards>
|
||||
{items.map((item) => <Card
|
||||
image={`https://parcelvoy.com/${item.docId}.svg`}
|
||||
key={item.docId}
|
||||
title={item.label}
|
||||
href={item.href}></Card>)}
|
||||
</Cards>
|
||||
</div>
|
||||
)
|
||||
}
|
12
docs/static/img/parcelvoy-light.svg
vendored
Normal file
12
docs/static/img/parcelvoy-light.svg
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
<svg width="196" height="39" viewBox="0 0 196 39" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M50.176 5.57602H40.132V30.776H44.092V22.424H50.176C56.044 22.424 59.428 19.256 59.428 14C59.428 8.74402 56.044 5.57602 50.176 5.57602ZM49.888 18.752H44.092V9.24802H49.888C53.56 9.24802 55.324 10.796 55.324 14C55.324 17.204 53.56 18.752 49.888 18.752Z" fill="white"/>
|
||||
<path d="M75.0368 13.892L74.8208 16.412H74.7128C73.7048 14.54 71.4728 13.496 68.9888 13.496C64.3088 13.496 60.6728 17.312 60.6728 22.316C60.6728 27.356 64.3088 31.172 68.9888 31.172C71.4728 31.172 73.7048 30.128 74.7128 28.256H74.8208L75.0368 30.776H78.4568V13.892H75.0368ZM64.4888 22.316C64.4888 19.22 66.5408 16.88 69.6728 16.88C72.8048 16.88 74.8568 19.22 74.8568 22.316C74.8568 25.412 72.8048 27.788 69.6728 27.788C66.5408 27.788 64.4888 25.412 64.4888 22.316Z" fill="white"/>
|
||||
<path d="M91.6446 13.568C89.2686 13.568 87.2526 14.792 86.3166 17.132H86.2086L85.9926 13.892H82.5726V30.776H86.3166V23.612C86.3166 19.112 88.2966 17.132 91.5366 17.132C92.1486 17.132 92.7606 17.204 93.1926 17.276V13.82C92.7246 13.64 92.2206 13.568 91.6446 13.568Z" fill="white"/>
|
||||
<path d="M103.018 31.172C107.554 31.172 110.974 28.364 111.334 24.332H107.518C107.122 26.528 105.358 27.788 103.054 27.788C99.9221 27.788 97.9421 25.52 97.9421 22.316C97.9421 19.148 99.9221 16.88 103.054 16.88C105.394 16.88 107.122 18.14 107.554 20.372H111.37C110.974 16.304 107.59 13.496 103.018 13.496C97.6541 13.496 94.1261 17.348 94.1261 22.316C94.1261 27.284 97.6541 31.172 103.018 31.172Z" fill="white"/>
|
||||
<path d="M130.966 22.244C130.966 16.988 127.438 13.496 122.362 13.496C117.106 13.496 113.542 17.204 113.542 22.352C113.542 27.536 117.07 31.172 122.47 31.172C127.006 31.172 129.994 28.688 130.678 25.556H127.078C126.646 27.068 124.99 28.112 122.506 28.112C119.554 28.112 117.682 26.564 117.322 23.504H130.858C130.93 23.108 130.966 22.748 130.966 22.244ZM122.362 16.52C125.026 16.52 126.718 17.924 127.15 20.804H117.358C117.826 17.96 119.626 16.52 122.362 16.52Z" fill="white"/>
|
||||
<path d="M134.106 30.776H137.85V5.57602H134.106V30.776Z" fill="white"/>
|
||||
<path d="M147.284 30.776H150.776L158.156 13.892H154.124L149.084 26.24H148.976L143.9 13.892H139.904L147.284 30.776Z" fill="white"/>
|
||||
<path d="M167.642 31.172C172.79 31.172 176.642 27.356 176.642 22.316C176.642 17.312 172.79 13.496 167.642 13.496C162.494 13.496 158.642 17.312 158.642 22.316C158.642 27.356 162.494 31.172 167.642 31.172ZM167.642 27.788C164.51 27.788 162.458 25.412 162.458 22.316C162.458 19.22 164.51 16.88 167.642 16.88C170.774 16.88 172.79 19.22 172.79 22.316C172.79 25.412 170.774 27.788 167.642 27.788Z" fill="white"/>
|
||||
<path d="M191.339 13.892L186.299 26.24H186.191L181.115 13.892H177.119L184.283 30.272L180.575 38.552H184.571L195.371 13.892H191.339Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.9788 0.626526L31.4444 8.35935V27.6648L15.9788 35.3977L0.513153 27.6648V8.35935L15.9788 0.626526ZM5.90762 9.92568C4.93867 10.4102 4.3266 11.4005 4.3266 12.4838V23.5404C4.3266 24.6237 4.93867 25.614 5.90762 26.0985L7.29265 26.791V16.4628C7.29265 13.5739 8.92482 10.933 11.5087 9.64107L18.4947 6.14805L17.2579 5.52962C16.4527 5.12703 15.5049 5.12703 14.6997 5.52962L5.90762 9.92568ZM14.6997 30.4946L12.2117 29.2505C11.5341 28.9118 11.1061 28.2192 11.1061 27.4617V24.2751L20.449 19.6037C23.0328 18.3117 24.665 15.6708 24.665 12.782V9.23319L26.05 9.92568C27.0189 10.4102 27.631 11.4005 27.631 12.4838V23.5404C27.631 24.6237 27.0189 25.614 26.05 26.0985L17.2579 30.4946C16.4527 30.8972 15.5049 30.8972 14.6997 30.4946ZM20.8516 9.2332V12.782C20.8516 14.2264 20.0355 15.5469 18.7435 16.1928L11.1061 20.0115V16.4628C11.1061 15.0183 11.9222 13.6979 13.2141 13.0519L20.8516 9.2332Z" fill="white"/>
|
||||
</svg>
|
After Width: | Height: | Size: 3.6 KiB |
Loading…
Add table
Add a link
Reference in a new issue