mirror of
https://fast.feibisi.com/https://github.com/parcelvoy/platform.git
synced 2025-09-07 13:26:27 +08:00
commit
0941659de2
28 changed files with 435 additions and 221 deletions
64
db/migrations/20220818010625_create_list.js
Normal file
64
db/migrations/20220818010625_create_list.js
Normal file
|
@ -0,0 +1,64 @@
|
|||
exports.up = function(knex) {
|
||||
return knex.schema
|
||||
.createTable('lists', function(table) {
|
||||
table.increments()
|
||||
table.string('name', 255).defaultTo('')
|
||||
table.integer('project_id')
|
||||
.unsigned()
|
||||
.notNullable()
|
||||
.references('id')
|
||||
.inTable('projects')
|
||||
.onDelete('CASCADE')
|
||||
table.json('rules')
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now())
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now())
|
||||
})
|
||||
.createTable('user_events', function(table) {
|
||||
table.increments()
|
||||
table.string('name', 255).defaultTo('')
|
||||
table.integer('project_id')
|
||||
.unsigned()
|
||||
.notNullable()
|
||||
.references('id')
|
||||
.inTable('projects')
|
||||
.onDelete('CASCADE')
|
||||
table.integer('user_id')
|
||||
.unsigned()
|
||||
.notNullable()
|
||||
.references('id')
|
||||
.inTable('users')
|
||||
.onDelete('CASCADE')
|
||||
table.json('data')
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now())
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now())
|
||||
})
|
||||
.createTable('user_list', function(table) {
|
||||
table.increments()
|
||||
table.integer('user_id')
|
||||
.unsigned()
|
||||
.notNullable()
|
||||
.references('id')
|
||||
.inTable('users')
|
||||
.onDelete('CASCADE')
|
||||
table.integer('list_id')
|
||||
.unsigned()
|
||||
.notNullable()
|
||||
.references('id')
|
||||
.inTable('lists')
|
||||
.onDelete('CASCADE')
|
||||
table.integer('event_id')
|
||||
.unsigned()
|
||||
.references('id')
|
||||
.inTable('user_events')
|
||||
.onDelete('CASCADE')
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now())
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now())
|
||||
})
|
||||
}
|
||||
|
||||
exports.down = function(knex) {
|
||||
knex.schema
|
||||
.dropTable('user_list')
|
||||
.dropTable('user_events')
|
||||
.dropTable('lists')
|
||||
}
|
|
@ -7,6 +7,7 @@ import Queue from './queue'
|
|||
import EmailSender from './sender/email/EmailSender'
|
||||
import TextSender from './sender/text/TextSender'
|
||||
import WebhookSender from './sender/webhook/WebhookSender'
|
||||
import configQueue from './config/queue'
|
||||
|
||||
export default class App {
|
||||
private static $main: App
|
||||
|
@ -27,6 +28,9 @@ export default class App {
|
|||
// Setup app
|
||||
App.$main = new App(env, database)
|
||||
|
||||
// Register jobs
|
||||
configQueue(App.$main.queue)
|
||||
|
||||
return App.$main
|
||||
}
|
||||
|
||||
|
|
17
src/config/queue.ts
Normal file
17
src/config/queue.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import Queue from '../queue'
|
||||
import EmailJob from '../jobs/EmailJob'
|
||||
import EventPostJob from '../jobs/EventPostJob'
|
||||
import TextJob from '../jobs/TextJob'
|
||||
import UserDeleteJob from '../jobs/UserDeleteJob'
|
||||
import UserPatchJob from '../jobs/UserPatchJob'
|
||||
import WebhookJob from '../jobs/WebhookJob'
|
||||
|
||||
export default (queue: Queue) => {
|
||||
|
||||
queue.register(EmailJob)
|
||||
queue.register(TextJob)
|
||||
queue.register(WebhookJob)
|
||||
queue.register(UserPatchJob)
|
||||
queue.register(UserDeleteJob)
|
||||
queue.register(EventPostJob)
|
||||
}
|
|
@ -1,80 +0,0 @@
|
|||
/* eslint-disable */
|
||||
|
||||
import Router from '@koa/router'
|
||||
import App from '../app'
|
||||
import { User } from '../models/User'
|
||||
import EmailJob from '../sender/email/EmailJob'
|
||||
|
||||
const router = new Router({
|
||||
prefix: '/users'
|
||||
})
|
||||
|
||||
const user = new User({
|
||||
id: 1,
|
||||
project_id: 1,
|
||||
external_id: "1",
|
||||
email: 'canderson@twochris.com',
|
||||
data: {
|
||||
firstName: 'Chris',
|
||||
lastName: 'Anderson',
|
||||
city: 'Chicago'
|
||||
},
|
||||
devices: [],
|
||||
attributes: [],
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
})
|
||||
|
||||
router.get('/', async (ctx, next) => {
|
||||
const email = {
|
||||
from: 'hi@chrisanderson.io',
|
||||
to: 'chrisanderson93@gmail.com',
|
||||
subject: 'Hello there {{user.firstName}}!',
|
||||
html: 'This is a test to see how sending a template would work.\n\nFirst Name: {{user.firstName}}\nLast Name: {{user.lastName}}\nEmail: {{user.email}} {{reverse user.email}} {{multiply (add 1 1) 10}}\n\n<br>{{numberFormat 100000}}',
|
||||
text: 'This is the text version seen in {{user.city}}'
|
||||
}
|
||||
|
||||
const job = EmailJob.from({
|
||||
email,
|
||||
user,
|
||||
event: {}
|
||||
})
|
||||
await App.main.queue.enqueue(job)
|
||||
|
||||
ctx.body = 'sent email!'
|
||||
})
|
||||
|
||||
router.get('/text', async (ctx, next) => {
|
||||
await App.main.texter.send(
|
||||
{
|
||||
to: '952-769-6903',
|
||||
from: '123-456-7890',
|
||||
text: 'You have won a fabulous prize {{ user.firstName }}!'
|
||||
},
|
||||
{
|
||||
user
|
||||
}
|
||||
)
|
||||
|
||||
ctx.body = 'sent text!'
|
||||
})
|
||||
|
||||
router.get('/webhook', async (ctx, next) => {
|
||||
await App.main.webhooker.send(
|
||||
{
|
||||
method: 'POST',
|
||||
endpoint: 'twochris.com/status',
|
||||
headers: { },
|
||||
body: {
|
||||
foo: 'bar {{ user.email }}'
|
||||
}
|
||||
},
|
||||
{
|
||||
user
|
||||
}
|
||||
)
|
||||
|
||||
ctx.body = 'sent webhook!'
|
||||
})
|
||||
|
||||
export default router
|
|
@ -1,9 +1,9 @@
|
|||
import { Job } from '../queue'
|
||||
import { User } from '../models/User'
|
||||
import App from '../app'
|
||||
import { UserEvent } from '../sender/journey/UserEvent'
|
||||
import { UserEvent } from '../journey/UserEvent'
|
||||
import { EmailTemplate } from '../models/Template'
|
||||
import { createEvent } from '../sender/journey/UserEventRepository'
|
||||
import { createEvent } from '../journey/UserEventRepository'
|
||||
import { MessageTrigger } from '../models/MessageTrigger'
|
||||
|
||||
export default class EmailJob extends Job {
|
||||
|
@ -27,8 +27,12 @@ export default class EmailJob extends Job {
|
|||
await App.main.mailer.send(template, { user, event })
|
||||
|
||||
// Create an event on the user about the email
|
||||
createEvent(user_id, 'email_sent', {
|
||||
// TODO: Add whatever other attributes
|
||||
await createEvent({
|
||||
project_id: user.project_id,
|
||||
user_id: user.id,
|
||||
name: 'email_sent',
|
||||
data: { // TODO: Add whatever other attributes
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
import { logger } from '../config/logger'
|
||||
import { createEvent } from '../journey/UserEventRepository'
|
||||
import { getUserFromExternalId } from '../journey/UserRepository'
|
||||
import { updateLists } from '../lists/ListService'
|
||||
import { ClientPostEvent } from '../models/client'
|
||||
import { Job } from '../queue'
|
||||
|
||||
|
@ -16,8 +19,18 @@ export default class EventPostJob extends Job {
|
|||
|
||||
static async handler({ project_id, event }: EventPostTrigger) {
|
||||
|
||||
// TODO: Handle events
|
||||
logger.debug('project ' + project_id + ' received event: ' + JSON.stringify(event))
|
||||
const user = await getUserFromExternalId(project_id, event.user_id)
|
||||
if (!user) return // TODO: Maybe log an error somewhere?
|
||||
|
||||
// Create event for given user
|
||||
const dbEvent = await createEvent({
|
||||
project_id,
|
||||
user_id: user.id,
|
||||
name: event.name,
|
||||
data: event.data || {},
|
||||
})
|
||||
|
||||
// Check to see if a user has any lists
|
||||
await updateLists(user, dbEvent)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { Job } from '../queue'
|
||||
import { User } from '../models/User'
|
||||
import App from '../app'
|
||||
import { UserEvent } from '../sender/journey/UserEvent'
|
||||
import { UserEvent } from '../journey/UserEvent'
|
||||
import { TextTemplate } from '../models/Template'
|
||||
import { createEvent } from '../sender/journey/UserEventRepository'
|
||||
import { createEvent } from '../journey/UserEventRepository'
|
||||
import { MessageTrigger } from '../models/MessageTrigger'
|
||||
|
||||
export default class TextJob extends Job {
|
||||
|
@ -27,8 +27,13 @@ export default class TextJob extends Job {
|
|||
await App.main.texter.send(template, { user, event })
|
||||
|
||||
// Create an event on the user about the text
|
||||
createEvent(user_id, 'text_sent', {
|
||||
// TODO: Add whatever other attributes
|
||||
createEvent({
|
||||
project_id: user.project_id,
|
||||
user_id: user.id,
|
||||
name: 'text_sent',
|
||||
data: {
|
||||
// TODO: Add whatever other attributes
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import App from '../app'
|
||||
import { User } from '../models/User'
|
||||
import { ClientPatchUser } from '../models/client'
|
||||
import { Job } from '../queue'
|
||||
import { getUserFromExternalId } from '../journey/UserRepository'
|
||||
import { updateLists } from '../lists/ListService'
|
||||
|
||||
interface UserPatchTrigger {
|
||||
project_id: number
|
||||
|
@ -17,30 +18,23 @@ export default class UserPatchJob extends Job {
|
|||
|
||||
static async handler({ project_id, user: { external_id, data, ...fields } }: UserPatchTrigger) {
|
||||
|
||||
await App.main.db.transaction(async trx => {
|
||||
// Check for existing user
|
||||
const existing = await getUserFromExternalId(project_id, external_id)
|
||||
|
||||
const existing = await trx('users')
|
||||
.where('project_id', project_id)
|
||||
.where('external_id', external_id)
|
||||
.first()
|
||||
.then(r => User.fromJson(r))
|
||||
// If user, update otherwise insert
|
||||
const user = existing
|
||||
? await User.updateAndFetch(existing.id, {
|
||||
data: data ? { ...existing.data, ...data } : undefined,
|
||||
...fields,
|
||||
})
|
||||
: await User.insertAndFetch({
|
||||
project_id,
|
||||
external_id,
|
||||
data,
|
||||
...fields,
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
await trx('users')
|
||||
.update({
|
||||
data: data ? JSON.stringify({ ...existing.data, ...data }) : undefined,
|
||||
...fields,
|
||||
})
|
||||
.where({ external_id })
|
||||
} else {
|
||||
await trx('users')
|
||||
.insert({
|
||||
project_id,
|
||||
external_id,
|
||||
data: JSON.stringify(data),
|
||||
...fields,
|
||||
})
|
||||
}
|
||||
})
|
||||
// Use updated user to check for list membership
|
||||
await updateLists(user)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,8 +3,8 @@ import { User } from '../models/User'
|
|||
import App from '../app'
|
||||
import { MessageTrigger } from '../models/MessageTrigger'
|
||||
import { WebhookTemplate } from '../models/Template'
|
||||
import { UserEvent } from '../sender/journey/UserEvent'
|
||||
import { createEvent } from '../sender/journey/UserEventRepository'
|
||||
import { UserEvent } from '../journey/UserEvent'
|
||||
import { createEvent } from '../journey/UserEventRepository'
|
||||
|
||||
export default class WebhookJob extends Job {
|
||||
static $name = 'webhook'
|
||||
|
@ -26,8 +26,13 @@ export default class WebhookJob extends Job {
|
|||
await App.main.webhooker.send(template, { user, event })
|
||||
|
||||
// Create an event on the user about the email
|
||||
createEvent(user_id, 'webhook_sent', {
|
||||
// TODO: Add whatever other attributes
|
||||
createEvent({
|
||||
project_id: user.project_id,
|
||||
user_id: user.id,
|
||||
name: 'webhook_sent',
|
||||
data: {
|
||||
// TODO: Add whatever other attributes
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import Model from '../../models/Model'
|
||||
import Model from '../models/Model'
|
||||
|
||||
export default class Journey extends Model {
|
||||
|
|
@ -1,20 +1,20 @@
|
|||
import { add, isFuture } from 'date-fns'
|
||||
import Model from '../../models/Model'
|
||||
import { User } from '../../models/User'
|
||||
import { Rule, check } from './RuleEngine'
|
||||
import Model from '../models/Model'
|
||||
import { User } from '../models/User'
|
||||
import Rule from '../rules/Rule'
|
||||
import { check } from '../rules/RuleEngine'
|
||||
import { getJourneyStep, getUserJourneyStep } from './JourneyRepository'
|
||||
import { UserEvent } from './UserEvent'
|
||||
import EmailJob from '../../jobs/EmailJob'
|
||||
import App from '../../app'
|
||||
import TextJob from '../../jobs/TextJob'
|
||||
import WebhookJob from '../../jobs/WebhookJob'
|
||||
import EmailJob from '../jobs/EmailJob'
|
||||
import App from '../app'
|
||||
import TextJob from '../jobs/TextJob'
|
||||
import WebhookJob from '../jobs/WebhookJob'
|
||||
|
||||
export class JourneyUserStep extends Model {
|
||||
user_id!: number
|
||||
type!: string
|
||||
journey_id!: number
|
||||
step_id!: number
|
||||
created_at!: Date
|
||||
|
||||
static tableName = 'journey_user_step'
|
||||
}
|
||||
|
@ -27,6 +27,7 @@ export class JourneyStep extends Model {
|
|||
|
||||
static tableName = 'journey_steps'
|
||||
get $name(): string { return this.constructor.name }
|
||||
static jsonAttributes = ['data']
|
||||
|
||||
async step(user: User, type: string) {
|
||||
await JourneyUserStep.insert({
|
||||
|
@ -134,13 +135,11 @@ export class JourneyStep extends Model {
|
|||
|
||||
export class JourneyEntrance extends JourneyStep {
|
||||
|
||||
entrance_type!: 'user' | 'event'
|
||||
rules: Rule[] = []
|
||||
|
||||
parseJson(json: any) {
|
||||
super.parseJson(json)
|
||||
|
||||
this.entrance_type = json?.data?.entrance_type
|
||||
this.rules = json?.data.rules
|
||||
}
|
||||
|
||||
|
@ -157,15 +156,10 @@ export class JourneyEntrance extends JourneyStep {
|
|||
}
|
||||
|
||||
async condition(user: User, event?: UserEvent): Promise<boolean> {
|
||||
|
||||
// Based on entrance type get flattened user or event
|
||||
const obj = this.entrance_type === 'user' ? user.flatten() : event?.flatten()
|
||||
|
||||
// If entrance is event based and we don't have an event, break
|
||||
if (!obj) return false
|
||||
|
||||
// Check that all rules are met
|
||||
return check(obj, this.rules)
|
||||
return check({
|
||||
user: user.flatten(),
|
||||
event: event?.flatten(),
|
||||
}, this.rules)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -272,15 +266,10 @@ export class JourneyGate extends JourneyStep {
|
|||
}
|
||||
|
||||
async condition(user: User, event?: UserEvent): Promise<boolean> {
|
||||
|
||||
// Based on entrance type get flattened user or event
|
||||
const obj = this.entrance_type === 'user' ? user.flatten() : event?.flatten()
|
||||
|
||||
// If entrance is event based and we don't have an event, break
|
||||
if (!obj) return false
|
||||
|
||||
// Check that all rules are met
|
||||
return check(obj, [this.rule])
|
||||
return check({
|
||||
user: user.flatten(),
|
||||
event: event?.flatten(),
|
||||
}, [this.rule])
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +1,23 @@
|
|||
import Model from '../../models/Model'
|
||||
import Model, { ModelParams } from '../models/Model'
|
||||
|
||||
export interface TemplateEvent extends Record<string, any> {
|
||||
name: string
|
||||
}
|
||||
|
||||
export class UserEvent extends Model {
|
||||
project_id!: number
|
||||
user_id!: number
|
||||
name!: string
|
||||
properties!: Record<string, any>
|
||||
created_at!: Date
|
||||
updated_at!: Date
|
||||
data!: Record<string, unknown>
|
||||
|
||||
static jsonAttributes = ['data']
|
||||
|
||||
flatten(): TemplateEvent {
|
||||
return {
|
||||
...this.properties,
|
||||
...this.data,
|
||||
name: this.name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type UserEventParams = Omit<UserEvent, ModelParams | 'flatten'>
|
5
src/journey/UserEventRepository.ts
Normal file
5
src/journey/UserEventRepository.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { UserEvent, UserEventParams } from './UserEvent'
|
||||
|
||||
export const createEvent = async (event: UserEventParams): Promise<UserEvent> => {
|
||||
return await UserEvent.insertAndFetch(event)
|
||||
}
|
8
src/journey/UserRepository.ts
Normal file
8
src/journey/UserRepository.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { User } from '../models/User'
|
||||
|
||||
export const getUserFromExternalId = async (projectId: number, externalId: string): Promise<User | undefined> => {
|
||||
return await User.first(
|
||||
qb => qb.where('external_id', externalId)
|
||||
.where('project_id', projectId),
|
||||
)
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import { Project } from '../../../models/Project'
|
||||
import { User } from '../../../models/User'
|
||||
import { Project } from '../../models/Project'
|
||||
import { User } from '../../models/User'
|
||||
import Journey from '../Journey'
|
||||
import { lastJourneyStep } from '../JourneyRepository'
|
||||
import JourneyService from '../JourneyService'
|
||||
|
@ -16,6 +16,7 @@ describe('Run', () => {
|
|||
}, journey.id)
|
||||
await JourneyEntrance.create('user', [{
|
||||
type: 'string',
|
||||
group: 'user',
|
||||
path: '$.language',
|
||||
operator: '=',
|
||||
value: 'en',
|
|
@ -1,4 +1,4 @@
|
|||
import { User } from '../../../models/User'
|
||||
import { User } from '../../models/User'
|
||||
import { JourneyStep, JourneyEntrance, JourneyMap } from '../JourneyStep'
|
||||
|
||||
describe('JourneyEntrance', () => {
|
||||
|
@ -9,6 +9,7 @@ describe('JourneyEntrance', () => {
|
|||
const user = User.fromJson({ email })
|
||||
const entrance = await JourneyEntrance.create('user', [{
|
||||
type: 'string',
|
||||
group: 'user',
|
||||
path: '$.email',
|
||||
operator: '=',
|
||||
value: email,
|
||||
|
@ -24,6 +25,7 @@ describe('JourneyEntrance', () => {
|
|||
const user = User.fromJson({ email })
|
||||
const entrance = await JourneyEntrance.create('user', [{
|
||||
type: 'string',
|
||||
group: 'user',
|
||||
path: '$.email',
|
||||
operator: '=',
|
||||
value: 'notequal@test.com',
|
||||
|
@ -39,6 +41,7 @@ describe('JourneyEntrance', () => {
|
|||
const user = User.fromJson({ email })
|
||||
const entrance = await JourneyEntrance.create('user', [{
|
||||
type: 'string',
|
||||
group: 'user',
|
||||
path: '$.email',
|
||||
operator: '!=',
|
||||
value: 'notequal@test.com',
|
||||
|
@ -54,6 +57,7 @@ describe('JourneyEntrance', () => {
|
|||
const user = User.fromJson({ email })
|
||||
const entrance = await JourneyEntrance.create('user', [{
|
||||
type: 'string',
|
||||
group: 'user',
|
||||
path: '$.email',
|
||||
operator: 'is set',
|
||||
}])
|
16
src/lists/List.ts
Normal file
16
src/lists/List.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import Model from '../models/Model'
|
||||
import Rule from '../rules/Rule'
|
||||
|
||||
export default class List extends Model {
|
||||
project_id!: number
|
||||
name!: string
|
||||
rules!: Rule[]
|
||||
}
|
||||
|
||||
export class UserList extends Model {
|
||||
user_id!: number
|
||||
list_id!: number
|
||||
event_id!: number
|
||||
|
||||
static tableName = 'user_list'
|
||||
}
|
35
src/lists/ListService.ts
Normal file
35
src/lists/ListService.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { UserEvent } from '../journey/UserEvent'
|
||||
import { User } from '../models/User'
|
||||
import { check } from '../rules/RuleEngine'
|
||||
import List, { UserList } from './List'
|
||||
|
||||
const getUserListIds = async (user_id: number): Promise<number[]> => {
|
||||
const relations = await UserList.all(qb => qb.where('user_id', user_id))
|
||||
return relations.map(item => item.list_id)
|
||||
}
|
||||
|
||||
export const updateLists = async (user: User, event?: UserEvent) => {
|
||||
const lists = await List.all(qb => qb.where('project_id', user.project_id))
|
||||
const existingLists = await getUserListIds(user.id)
|
||||
|
||||
for (const list of lists) {
|
||||
|
||||
// TODO: Check that the user wasn't previously unsubscribed from list
|
||||
|
||||
// Check to see if user condition matches list requirements
|
||||
const result = check({
|
||||
user: user.flatten(),
|
||||
event: event?.flatten(),
|
||||
}, list.rules)
|
||||
|
||||
// If check passes and user isn't already in the list, add
|
||||
if (result && !existingLists.includes(list.id)) {
|
||||
|
||||
await UserList.insert({
|
||||
user_id: user.id,
|
||||
list_id: list.id,
|
||||
event_id: event?.id ?? undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
|
@ -5,6 +5,10 @@ import { pascalToSnakeCase, pluralize } from '../utilities'
|
|||
export default class Model {
|
||||
|
||||
id!: number
|
||||
created_at: Date = new Date()
|
||||
updated_at: Date = new Date()
|
||||
|
||||
static jsonAttributes: string[] = []
|
||||
|
||||
static fromJson<T extends typeof Model>(this: T, json: Partial<InstanceType<T>>): InstanceType<T> {
|
||||
const model = new this()
|
||||
|
@ -16,6 +20,13 @@ export default class Model {
|
|||
Object.assign(this, json)
|
||||
}
|
||||
|
||||
static formatJson(json: any): Record<string, unknown> {
|
||||
for (const attribute of this.jsonAttributes) {
|
||||
json[attribute] = JSON.stringify(json[attribute])
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
static query<T extends typeof Model>(this: T, db: Knex = App.main.db): Knex.QueryBuilder<InstanceType<T>> {
|
||||
return this.table(db)
|
||||
}
|
||||
|
@ -55,7 +66,8 @@ export default class Model {
|
|||
data: Partial<InstanceType<T>> = {},
|
||||
db: Knex = App.main.db,
|
||||
): Promise<number> {
|
||||
return await this.table(db).insert(data)
|
||||
const formattedData = this.formatJson(data)
|
||||
return await this.table(db).insert(formattedData)
|
||||
}
|
||||
|
||||
static async insertAndFetch<T extends typeof Model>(
|
||||
|
@ -63,17 +75,31 @@ export default class Model {
|
|||
data: Partial<InstanceType<T>> = {},
|
||||
db: Knex = App.main.db,
|
||||
): Promise<InstanceType<T>> {
|
||||
const id: number = await this.table(db).insert(data)
|
||||
const formattedData = this.formatJson(data)
|
||||
const id: number = await this.table(db).insert(formattedData)
|
||||
return await this.find(id) as InstanceType<T>
|
||||
}
|
||||
|
||||
static async update<T extends typeof Model>(
|
||||
this: T,
|
||||
where: (builder: Knex.QueryBuilder<any>) => Knex.QueryBuilder<any>,
|
||||
data: any = {},
|
||||
data: Partial<InstanceType<T>> = {},
|
||||
db: Knex = App.main.db,
|
||||
): Promise<number> {
|
||||
return await where(this.table(db)).update(data)
|
||||
const formattedData = this.formatJson(data)
|
||||
return await where(this.table(db)).update(formattedData)
|
||||
}
|
||||
|
||||
static async updateAndFetch<T extends typeof Model>(
|
||||
this: T,
|
||||
id: number,
|
||||
data: Partial<InstanceType<T>> = {},
|
||||
db: Knex = App.main.db,
|
||||
): Promise<InstanceType<T>> {
|
||||
const formattedData = this.formatJson(data)
|
||||
console.log(formattedData)
|
||||
await this.table(db).where('id', id).update(formattedData)
|
||||
return await this.find(id) as InstanceType<T>
|
||||
}
|
||||
|
||||
static async delete<T extends typeof Model>(
|
||||
|
@ -92,3 +118,5 @@ export default class Model {
|
|||
return db(this.tableName)
|
||||
}
|
||||
}
|
||||
|
||||
export type ModelParams = 'id' | 'created_at' | 'updated_at' | 'parseJson'
|
||||
|
|
|
@ -4,26 +4,16 @@ export class Project extends Model {
|
|||
|
||||
public name!: string
|
||||
public description?: string
|
||||
public created_at!: Date
|
||||
public updated_at!: Date
|
||||
public deleted_at?: Date
|
||||
}
|
||||
|
||||
export type ProjectParams = Omit<Project, 'id' | 'created_at' | 'updated_at' | 'deleted_at' | 'parseJson'>
|
||||
|
||||
export class ProjectApiKey {
|
||||
export class ProjectApiKey extends Model {
|
||||
|
||||
id!: number
|
||||
project_id!: number
|
||||
value!: string
|
||||
name!: string
|
||||
description?: string
|
||||
created_at!: Date
|
||||
updated_at!: Date
|
||||
deleted_at?: Date
|
||||
|
||||
constructor(json: any) {
|
||||
Object.assign(this, json)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -32,8 +32,8 @@ export class User extends Model {
|
|||
devices!: Device[]
|
||||
data!: Record<string, any> // first_name, last_name live in data
|
||||
attributes!: UserAttribute[] //???
|
||||
created_at!: Date
|
||||
updated_at!: Date
|
||||
|
||||
static jsonAttributes = ['data']
|
||||
|
||||
flatten(): TemplateUser {
|
||||
return {
|
||||
|
|
|
@ -4,12 +4,6 @@ import MemoryQueueProvider, { MemoryConfig } from './MemoryQueueProvider'
|
|||
import { LoggerConfig } from '../config/logger'
|
||||
import QueueProvider from './QueueProvider'
|
||||
import { DriverConfig } from '../config/env'
|
||||
import EmailJob from '../jobs/EmailJob'
|
||||
import UserPatchJob from '../jobs/UserPatchJob'
|
||||
import UserDeleteJob from '../jobs/UserDeleteJob'
|
||||
import EventPostJob from '../jobs/EventPostJob'
|
||||
import TextJob from '../jobs/TextJob'
|
||||
import WebhookJob from '../jobs/WebhookJob'
|
||||
|
||||
export type QueueDriver = 'sqs' | 'memory' | 'logger'
|
||||
export type QueueConfig = SQSConfig | MemoryConfig | LoggerConfig
|
||||
|
@ -30,13 +24,6 @@ export default class Queue {
|
|||
} else {
|
||||
throw new Error('A valid queue must be defined!')
|
||||
}
|
||||
|
||||
this.register(EmailJob)
|
||||
this.register(TextJob)
|
||||
this.register(WebhookJob)
|
||||
this.register(UserPatchJob)
|
||||
this.register(UserDeleteJob)
|
||||
this.register(EventPostJob)
|
||||
}
|
||||
|
||||
async dequeue(job: EncodedJob): Promise<boolean> {
|
||||
|
|
12
src/rules/Rule.ts
Normal file
12
src/rules/Rule.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
export type Operator = '=' | '!=' | '<' |'<=' | '>' | '>=' | '=' | 'is set' | 'is not set' | 'or' | 'and' | 'xor'
|
||||
export type RuleType = 'wrapper' | 'string' | 'number' | 'boolean' | 'date' | 'array'
|
||||
export type RuleGroup = 'user' | 'event'
|
||||
|
||||
export default interface Rule {
|
||||
type: RuleType
|
||||
group: RuleGroup
|
||||
path: string
|
||||
operator: Operator
|
||||
value?: unknown
|
||||
children?: Rule[]
|
||||
}
|
|
@ -1,15 +1,7 @@
|
|||
import jsonpath from 'jsonpath'
|
||||
|
||||
export type Operator = '=' | '!=' | '<' |'<=' | '>' | '>=' | '=' | 'is set' | 'is not set' | 'or' | 'and' | 'xor'
|
||||
export type RuleType = 'wrapper' | 'string' | 'number' | 'boolean' | 'date' | 'array'
|
||||
|
||||
export interface Rule {
|
||||
type: RuleType
|
||||
path: string
|
||||
operator: Operator
|
||||
value?: unknown
|
||||
children?: Rule[]
|
||||
}
|
||||
import { TemplateEvent } from '../journey/UserEvent'
|
||||
import { TemplateUser } from '../models/User'
|
||||
import Rule, { Operator, RuleGroup, RuleType } from './Rule'
|
||||
|
||||
class Registry<T> {
|
||||
#registered: { [key: string]: T } = {}
|
||||
|
@ -24,8 +16,13 @@ class Registry<T> {
|
|||
}
|
||||
}
|
||||
|
||||
interface RuleCheckInput {
|
||||
user: TemplateUser
|
||||
event?: TemplateEvent
|
||||
}
|
||||
|
||||
interface RuleCheck {
|
||||
check(value: Record<string, unknown>, rule: Rule): boolean
|
||||
check(value: RuleCheckInput, rule: Rule): boolean
|
||||
}
|
||||
|
||||
const ruleRegistry = new Registry<RuleCheck>()
|
||||
|
@ -36,8 +33,16 @@ class RuleEvalException extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
const queryValue = <T>(input: RuleCheckInput, rule: Rule, cast: (item: any) => T): T | undefined => {
|
||||
const inputValue = input[rule.group]
|
||||
if (!inputValue) return undefined
|
||||
const pathValue = jsonpath.query(input[rule.group], rule.path)
|
||||
if (!pathValue) return undefined
|
||||
return cast(pathValue)
|
||||
}
|
||||
|
||||
const WrapperRule: RuleCheck = {
|
||||
check(input: Record<string, unknown>, rule: Rule) {
|
||||
check(input: RuleCheckInput, rule: Rule) {
|
||||
const predicate = (child: Rule) => ruleRegistry.get(child.type)?.check(input, child)
|
||||
if (!rule.children) return true
|
||||
|
||||
|
@ -58,8 +63,10 @@ const WrapperRule: RuleCheck = {
|
|||
}
|
||||
|
||||
const StringRule: RuleCheck = {
|
||||
check(input: Record<string, unknown>, rule: Rule) {
|
||||
const value = String(jsonpath.query(input, rule.path))
|
||||
check(input: RuleCheckInput, rule: Rule) {
|
||||
const value = queryValue(input, rule, item => String(item))
|
||||
if (!value) return false
|
||||
|
||||
if (rule.operator === '=') {
|
||||
return value === rule.value
|
||||
}
|
||||
|
@ -77,8 +84,9 @@ const StringRule: RuleCheck = {
|
|||
}
|
||||
|
||||
const NumberRule: RuleCheck = {
|
||||
check(input: Record<string, unknown>, rule: Rule) {
|
||||
const value = Number(jsonpath.query(input, rule.path))
|
||||
check(input: RuleCheckInput, rule: Rule) {
|
||||
const value = queryValue(input, rule, item => Number(item))
|
||||
if (!value) return false
|
||||
|
||||
if (rule.operator === 'is set') {
|
||||
return value != null
|
||||
|
@ -122,8 +130,8 @@ const NumberRule: RuleCheck = {
|
|||
}
|
||||
|
||||
const BooleanRule: RuleCheck = {
|
||||
check(input: Record<string, unknown>, rule: Rule) {
|
||||
const value = Boolean(jsonpath.query(input, rule.path))
|
||||
check(input: RuleCheckInput, rule: Rule) {
|
||||
const value = queryValue(input, rule, item => Boolean(item))
|
||||
return value === rule.value
|
||||
},
|
||||
}
|
||||
|
@ -134,13 +142,22 @@ ruleRegistry.register('boolean', BooleanRule)
|
|||
// TODO: Add dates ruleset
|
||||
ruleRegistry.register('wrapper', WrapperRule)
|
||||
|
||||
export const check = (value: Record<string, unknown>, rules: Rule[]) => {
|
||||
const baseRule = make('wrapper', '$', 'and', undefined, rules)
|
||||
export const check = (value: RuleCheckInput, rules: Rule[]) => {
|
||||
const baseRule = make({ type: 'wrapper', operator: 'and', children: rules })
|
||||
return ruleRegistry.get('wrapper').check(value, baseRule)
|
||||
}
|
||||
|
||||
export const make = (type: RuleType, path = '$', operator: Operator = '=', value?: unknown, children?: Rule[]): Rule => {
|
||||
interface RuleMake {
|
||||
type: RuleType
|
||||
group?: RuleGroup
|
||||
path?: string
|
||||
operator?: Operator
|
||||
value?: unknown
|
||||
children?: Rule[]
|
||||
}
|
||||
|
||||
export const make = ({ type, group = 'user', path = '$', operator = '=', value, children }: RuleMake): Rule => {
|
||||
return {
|
||||
type, path, operator, value, children,
|
||||
type, group, path, operator, value, children,
|
||||
}
|
||||
}
|
106
src/rules/__test__/RuleEngine.spec.ts
Normal file
106
src/rules/__test__/RuleEngine.spec.ts
Normal file
|
@ -0,0 +1,106 @@
|
|||
import { check, make } from '../RuleEngine'
|
||||
|
||||
describe('RuleEngine', () => {
|
||||
describe('string', () => {
|
||||
test('equals', () => {
|
||||
const email = 'test@test.com'
|
||||
const value = {
|
||||
user: {
|
||||
id: 'abcd',
|
||||
email,
|
||||
name: 'Name',
|
||||
},
|
||||
}
|
||||
const shouldPass = check(value, [
|
||||
make({ type: 'string', path: '$.email', value: email }),
|
||||
])
|
||||
|
||||
expect(shouldPass).toBeTruthy()
|
||||
})
|
||||
|
||||
test('does not equals', () => {
|
||||
const email = 'test@test.com'
|
||||
const value = {
|
||||
user: {
|
||||
id: 'abcd',
|
||||
email,
|
||||
name: 'Name',
|
||||
},
|
||||
}
|
||||
const shouldPass = check(value, [
|
||||
make({ type: 'string', path: '$.email', operator: '!=', value: email }),
|
||||
])
|
||||
expect(shouldPass).toBeFalsy()
|
||||
})
|
||||
|
||||
test('is set', () => {
|
||||
const value = {
|
||||
user: {
|
||||
id: 'abcd',
|
||||
email: 'test@test.com',
|
||||
name: 'Name',
|
||||
},
|
||||
}
|
||||
const shouldPass = check(value, [
|
||||
make({ type: 'string', path: '$.project', operator: 'is set' }),
|
||||
])
|
||||
expect(shouldPass).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('multiple', () => {
|
||||
test('combination event and user and types', () => {
|
||||
const value = {
|
||||
user: {
|
||||
id: 'abcd',
|
||||
email: 'test@test.com',
|
||||
name: 'Name',
|
||||
project: 'Parcelvoy',
|
||||
},
|
||||
event: {
|
||||
name: 'beat-game',
|
||||
score: {
|
||||
total: 5,
|
||||
isRecord: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
const shouldPass = check(value, [
|
||||
make({ type: 'string', path: '$.project', operator: 'is set' }),
|
||||
make({ type: 'number', group: 'event', path: '$.score.total', operator: '<=', value: 5 }),
|
||||
make({ type: 'boolean', group: 'event', path: '$.score.isRecord', value: true }),
|
||||
])
|
||||
expect(shouldPass).toBeTruthy()
|
||||
})
|
||||
|
||||
test('combination of conditional clauses on rules', () => {
|
||||
const value = {
|
||||
user: {
|
||||
id: 'abcd',
|
||||
email: 'test@test.com',
|
||||
name: 'Name',
|
||||
project: 'Parcelvoy',
|
||||
},
|
||||
event: {
|
||||
name: 'beat-game',
|
||||
score: {
|
||||
total: 5,
|
||||
isRecord: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
const shouldPass = check(value, [
|
||||
make({ type: 'string', path: '$.project', operator: 'is set' }),
|
||||
make({
|
||||
type: 'wrapper',
|
||||
operator: 'or',
|
||||
children: [
|
||||
make({ type: 'number', group: 'event', path: '$.score.total', operator: '<', value: 5 }),
|
||||
make({ type: 'boolean', group: 'event', path: '$.score.isRecord', value: true }),
|
||||
],
|
||||
}),
|
||||
])
|
||||
expect(shouldPass).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
|
@ -1,13 +0,0 @@
|
|||
import { UserEvent } from './UserEvent'
|
||||
|
||||
export const createEvent = async (
|
||||
user_id: number,
|
||||
name: string,
|
||||
properties: Record<string, any>,
|
||||
): Promise<void> => {
|
||||
UserEvent.insert({
|
||||
user_id,
|
||||
name,
|
||||
properties,
|
||||
})
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue