diff --git a/db/migrations/20220818010625_create_list.js b/db/migrations/20220818010625_create_list.js
new file mode 100644
index 00000000..9e425199
--- /dev/null
+++ b/db/migrations/20220818010625_create_list.js
@@ -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')
+}
diff --git a/src/app.ts b/src/app.ts
index 9f435038..43422ae6 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -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
}
diff --git a/src/config/queue.ts b/src/config/queue.ts
new file mode 100644
index 00000000..bbc2bda7
--- /dev/null
+++ b/src/config/queue.ts
@@ -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)
+}
diff --git a/src/controllers/UserController.ts b/src/controllers/UserController.ts
deleted file mode 100644
index e8cb6b6f..00000000
--- a/src/controllers/UserController.ts
+++ /dev/null
@@ -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
{{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
\ No newline at end of file
diff --git a/src/jobs/EmailJob.ts b/src/jobs/EmailJob.ts
index 20db0736..77073b96 100644
--- a/src/jobs/EmailJob.ts
+++ b/src/jobs/EmailJob.ts
@@ -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
+ },
})
}
}
diff --git a/src/jobs/EventPostJob.ts b/src/jobs/EventPostJob.ts
index 712af9be..3d43f5c0 100644
--- a/src/jobs/EventPostJob.ts
+++ b/src/jobs/EventPostJob.ts
@@ -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)
}
}
diff --git a/src/jobs/TextJob.ts b/src/jobs/TextJob.ts
index 73e32c7e..e7b42027 100644
--- a/src/jobs/TextJob.ts
+++ b/src/jobs/TextJob.ts
@@ -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
+ },
})
}
}
diff --git a/src/jobs/UserPatchJob.ts b/src/jobs/UserPatchJob.ts
index 925d078d..1fb2ed9c 100644
--- a/src/jobs/UserPatchJob.ts
+++ b/src/jobs/UserPatchJob.ts
@@ -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)
}
}
diff --git a/src/jobs/WebhookJob.ts b/src/jobs/WebhookJob.ts
index bbd5775e..5af65748 100644
--- a/src/jobs/WebhookJob.ts
+++ b/src/jobs/WebhookJob.ts
@@ -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
+ },
})
}
}
diff --git a/src/sender/journey/Journey.ts b/src/journey/Journey.ts
similarity index 55%
rename from src/sender/journey/Journey.ts
rename to src/journey/Journey.ts
index e1fdd920..abdfa92b 100644
--- a/src/sender/journey/Journey.ts
+++ b/src/journey/Journey.ts
@@ -1,4 +1,4 @@
-import Model from '../../models/Model'
+import Model from '../models/Model'
export default class Journey extends Model {
diff --git a/src/sender/journey/JourneyRepository.ts b/src/journey/JourneyRepository.ts
similarity index 100%
rename from src/sender/journey/JourneyRepository.ts
rename to src/journey/JourneyRepository.ts
diff --git a/src/sender/journey/JourneyService.ts b/src/journey/JourneyService.ts
similarity index 100%
rename from src/sender/journey/JourneyService.ts
rename to src/journey/JourneyService.ts
diff --git a/src/sender/journey/JourneyStep.ts b/src/journey/JourneyStep.ts
similarity index 87%
rename from src/sender/journey/JourneyStep.ts
rename to src/journey/JourneyStep.ts
index b5f9c341..a6e4fea1 100644
--- a/src/sender/journey/JourneyStep.ts
+++ b/src/journey/JourneyStep.ts
@@ -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 {
-
- // 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 {
-
- // 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])
}
}
diff --git a/src/sender/journey/UserEvent.ts b/src/journey/UserEvent.ts
similarity index 50%
rename from src/sender/journey/UserEvent.ts
rename to src/journey/UserEvent.ts
index fa3501fc..643e867f 100644
--- a/src/sender/journey/UserEvent.ts
+++ b/src/journey/UserEvent.ts
@@ -1,20 +1,23 @@
-import Model from '../../models/Model'
+import Model, { ModelParams } from '../models/Model'
export interface TemplateEvent extends Record {
name: string
}
export class UserEvent extends Model {
+ project_id!: number
user_id!: number
name!: string
- properties!: Record
- created_at!: Date
- updated_at!: Date
+ data!: Record
+
+ static jsonAttributes = ['data']
flatten(): TemplateEvent {
return {
- ...this.properties,
+ ...this.data,
name: this.name,
}
}
}
+
+export type UserEventParams = Omit
diff --git a/src/journey/UserEventRepository.ts b/src/journey/UserEventRepository.ts
new file mode 100644
index 00000000..44f1036f
--- /dev/null
+++ b/src/journey/UserEventRepository.ts
@@ -0,0 +1,5 @@
+import { UserEvent, UserEventParams } from './UserEvent'
+
+export const createEvent = async (event: UserEventParams): Promise => {
+ return await UserEvent.insertAndFetch(event)
+}
diff --git a/src/journey/UserRepository.ts b/src/journey/UserRepository.ts
new file mode 100644
index 00000000..d5557dc6
--- /dev/null
+++ b/src/journey/UserRepository.ts
@@ -0,0 +1,8 @@
+import { User } from '../models/User'
+
+export const getUserFromExternalId = async (projectId: number, externalId: string): Promise => {
+ return await User.first(
+ qb => qb.where('external_id', externalId)
+ .where('project_id', projectId),
+ )
+}
diff --git a/src/sender/journey/__test__/JourneyService.spec.ts b/src/journey/__test__/JourneyService.spec.ts
similarity index 91%
rename from src/sender/journey/__test__/JourneyService.spec.ts
rename to src/journey/__test__/JourneyService.spec.ts
index bf4996cd..eec14b24 100644
--- a/src/sender/journey/__test__/JourneyService.spec.ts
+++ b/src/journey/__test__/JourneyService.spec.ts
@@ -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',
diff --git a/src/sender/journey/__test__/JourneyStep.spec.ts b/src/journey/__test__/JourneyStep.spec.ts
similarity index 94%
rename from src/sender/journey/__test__/JourneyStep.spec.ts
rename to src/journey/__test__/JourneyStep.spec.ts
index a1866f1f..d96eeb41 100644
--- a/src/sender/journey/__test__/JourneyStep.spec.ts
+++ b/src/journey/__test__/JourneyStep.spec.ts
@@ -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',
}])
diff --git a/src/lists/List.ts b/src/lists/List.ts
new file mode 100644
index 00000000..34297eb8
--- /dev/null
+++ b/src/lists/List.ts
@@ -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'
+}
diff --git a/src/lists/ListService.ts b/src/lists/ListService.ts
new file mode 100644
index 00000000..f0698816
--- /dev/null
+++ b/src/lists/ListService.ts
@@ -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 => {
+ 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,
+ })
+ }
+ }
+}
diff --git a/src/models/Model.ts b/src/models/Model.ts
index ec076b32..3a3367e2 100644
--- a/src/models/Model.ts
+++ b/src/models/Model.ts
@@ -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(this: T, json: Partial>): InstanceType {
const model = new this()
@@ -16,6 +20,13 @@ export default class Model {
Object.assign(this, json)
}
+ static formatJson(json: any): Record {
+ for (const attribute of this.jsonAttributes) {
+ json[attribute] = JSON.stringify(json[attribute])
+ }
+ return json
+ }
+
static query(this: T, db: Knex = App.main.db): Knex.QueryBuilder> {
return this.table(db)
}
@@ -55,7 +66,8 @@ export default class Model {
data: Partial> = {},
db: Knex = App.main.db,
): Promise {
- return await this.table(db).insert(data)
+ const formattedData = this.formatJson(data)
+ return await this.table(db).insert(formattedData)
}
static async insertAndFetch(
@@ -63,17 +75,31 @@ export default class Model {
data: Partial> = {},
db: Knex = App.main.db,
): Promise> {
- 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
}
static async update(
this: T,
where: (builder: Knex.QueryBuilder) => Knex.QueryBuilder,
- data: any = {},
+ data: Partial> = {},
db: Knex = App.main.db,
): Promise {
- return await where(this.table(db)).update(data)
+ const formattedData = this.formatJson(data)
+ return await where(this.table(db)).update(formattedData)
+ }
+
+ static async updateAndFetch(
+ this: T,
+ id: number,
+ data: Partial> = {},
+ db: Knex = App.main.db,
+ ): Promise> {
+ const formattedData = this.formatJson(data)
+ console.log(formattedData)
+ await this.table(db).where('id', id).update(formattedData)
+ return await this.find(id) as InstanceType
}
static async delete(
@@ -92,3 +118,5 @@ export default class Model {
return db(this.tableName)
}
}
+
+export type ModelParams = 'id' | 'created_at' | 'updated_at' | 'parseJson'
diff --git a/src/models/Project.ts b/src/models/Project.ts
index 79e34917..d236e3d0 100644
--- a/src/models/Project.ts
+++ b/src/models/Project.ts
@@ -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
-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)
- }
-
}
diff --git a/src/models/User.ts b/src/models/User.ts
index 90c889c7..f104b0f8 100644
--- a/src/models/User.ts
+++ b/src/models/User.ts
@@ -32,8 +32,8 @@ export class User extends Model {
devices!: Device[]
data!: Record // first_name, last_name live in data
attributes!: UserAttribute[] //???
- created_at!: Date
- updated_at!: Date
+
+ static jsonAttributes = ['data']
flatten(): TemplateUser {
return {
diff --git a/src/queue/Queue.ts b/src/queue/Queue.ts
index 0462200a..0de74165 100644
--- a/src/queue/Queue.ts
+++ b/src/queue/Queue.ts
@@ -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 {
diff --git a/src/rules/Rule.ts b/src/rules/Rule.ts
new file mode 100644
index 00000000..19a0f93e
--- /dev/null
+++ b/src/rules/Rule.ts
@@ -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[]
+}
diff --git a/src/sender/journey/RuleEngine.ts b/src/rules/RuleEngine.ts
similarity index 66%
rename from src/sender/journey/RuleEngine.ts
rename to src/rules/RuleEngine.ts
index 58c90886..4ba0e8fa 100644
--- a/src/sender/journey/RuleEngine.ts
+++ b/src/rules/RuleEngine.ts
@@ -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 {
#registered: { [key: string]: T } = {}
@@ -24,8 +16,13 @@ class Registry {
}
}
+interface RuleCheckInput {
+ user: TemplateUser
+ event?: TemplateEvent
+}
+
interface RuleCheck {
- check(value: Record, rule: Rule): boolean
+ check(value: RuleCheckInput, rule: Rule): boolean
}
const ruleRegistry = new Registry()
@@ -36,8 +33,16 @@ class RuleEvalException extends Error {
}
}
+const queryValue = (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, 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, 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, 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, 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, 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,
}
}
diff --git a/src/rules/__test__/RuleEngine.spec.ts b/src/rules/__test__/RuleEngine.spec.ts
new file mode 100644
index 00000000..6f378622
--- /dev/null
+++ b/src/rules/__test__/RuleEngine.spec.ts
@@ -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()
+ })
+ })
+})
diff --git a/src/sender/journey/UserEventRepository.ts b/src/sender/journey/UserEventRepository.ts
deleted file mode 100644
index ef12d0e3..00000000
--- a/src/sender/journey/UserEventRepository.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { UserEvent } from './UserEvent'
-
-export const createEvent = async (
- user_id: number,
- name: string,
- properties: Record,
-): Promise => {
- UserEvent.insert({
- user_id,
- name,
- properties,
- })
-}