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, - }) -}