mirror of
https://fast.feibisi.com/https://github.com/parcelvoy/platform.git
synced 2025-09-04 12:56:14 +08:00
Merge pull request #48 from parcelvoy/feat/journey-edit
WIP?: adds tags, journey step child, journey step save methods
This commit is contained in:
commit
559d0978a0
17 changed files with 641 additions and 125 deletions
44
db/migrations/20221210193723_add_tags.js
Normal file
44
db/migrations/20221210193723_add_tags.js
Normal file
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
exports.up = function(knex) {
|
||||
return knex.schema
|
||||
.createTable('tags', function(table) {
|
||||
table.increments()
|
||||
table.integer('project_id')
|
||||
.unsigned()
|
||||
.notNullable()
|
||||
.references('id')
|
||||
.inTable('projects')
|
||||
.onDelete('CASCADE')
|
||||
table.string('name', 255).defaultTo('')
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now())
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now())
|
||||
})
|
||||
.createTable('entity_tags', function(table) {
|
||||
table.increments()
|
||||
table.integer('tag_id')
|
||||
.unsigned()
|
||||
.notNullable()
|
||||
.references('id')
|
||||
.inTable('tags')
|
||||
.onDelete('CASCADE')
|
||||
table.string('entity', 255)
|
||||
table.integer('entity_id')
|
||||
.unsigned()
|
||||
.notNullable() // no fk constraint
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now())
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now())
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
exports.down = function(knex) {
|
||||
return knex.schema
|
||||
.dropTable('entity_tags')
|
||||
.dropTable('tags')
|
||||
}
|
52
db/migrations/20230120144724_add_journey_step_uuid.js
Normal file
52
db/migrations/20230120144724_add_journey_step_uuid.js
Normal file
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
exports.up = async function(knex) {
|
||||
await knex.schema.alterTable('journey_steps', function(table) {
|
||||
table.uuid('uuid').defaultTo()
|
||||
table.integer('x')
|
||||
.notNullable()
|
||||
.defaultTo(0)
|
||||
.alter()
|
||||
table.integer('y')
|
||||
.notNullable()
|
||||
.defaultTo(0)
|
||||
.alter()
|
||||
table.unique(['journey_id', 'uuid'])
|
||||
})
|
||||
await knex.schema.createTable('journey_step_child', function(table) {
|
||||
table.increments()
|
||||
table.integer('step_id')
|
||||
.unsigned()
|
||||
.notNullable()
|
||||
.references('id')
|
||||
.inTable('journey_steps')
|
||||
.onDelete('CASCADE')
|
||||
table.integer('child_id')
|
||||
.unsigned()
|
||||
.notNullable()
|
||||
.references('id')
|
||||
.inTable('journey_steps')
|
||||
.onDelete('CASCADE')
|
||||
table.json('data')
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now())
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now())
|
||||
table.unique(['step_id', 'child_id'])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
exports.down = function(knex) {
|
||||
return knex.schema
|
||||
.alterTable('journey_steps', function(table) {
|
||||
table.dropUnique(['journey_id', 'uuid'])
|
||||
table.dropColumn('uuid')
|
||||
table.integer('x').nullable().alter()
|
||||
table.integer('y').nullable().alter()
|
||||
})
|
||||
.dropTable('journey_step_child')
|
||||
}
|
23
db/migrations/20230204200316_step_type_x_y_float.js
Normal file
23
db/migrations/20230204200316_step_type_x_y_float.js
Normal file
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
exports.up = function(knex) {
|
||||
return knex.schema
|
||||
.alterTable('journey_steps', function(table) {
|
||||
table.float('x').notNullable().defaultTo(0).alter()
|
||||
table.float('y').notNullable().defaultTo(0).alter()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
exports.down = function(knex) {
|
||||
return knex.schema
|
||||
.alterTable('journey_steps', function(table) {
|
||||
table.integer('x').notNullable().defaultTo(0).alter()
|
||||
table.integer('y').notNullable().defaultTo(0).alter()
|
||||
})
|
||||
}
|
|
@ -17,12 +17,17 @@ import { getSubscription } from '../subscriptions/SubscriptionService'
|
|||
import { getProvider } from '../channels/ProviderRepository'
|
||||
import { isFuture } from 'date-fns'
|
||||
import { pick } from '../utilities'
|
||||
import { createTagSubquery } from '../tags/TagService'
|
||||
|
||||
export const pagedCampaigns = async (params: SearchParams, projectId: number) => {
|
||||
return await Campaign.searchParams(
|
||||
params,
|
||||
['name'],
|
||||
b => b.where({ project_id: projectId }).whereNull('deleted_at'),
|
||||
b => {
|
||||
b.where({ project_id: projectId })
|
||||
params?.tags?.length && b.whereIn('id', createTagSubquery(Campaign, projectId, params.tags))
|
||||
return b
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -12,6 +12,7 @@ import LinkController from '../render/LinkController'
|
|||
import TemplateController from '../render/TemplateController'
|
||||
import UserController from '../users/UserController'
|
||||
import ProfileController from '../profile/ProfileController'
|
||||
import TagController from '../tags/TagController'
|
||||
import { authMiddleware, scopeMiddleware } from '../auth/AuthMiddleware'
|
||||
import ProjectAdminController from '../projects/ProjectAdminController'
|
||||
|
||||
|
@ -75,6 +76,7 @@ export const projectRouter = (prefix = '/projects/:project') => {
|
|||
ProviderController,
|
||||
ProjectAdminController,
|
||||
UserController,
|
||||
TagController,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -141,10 +141,10 @@ export default class Model {
|
|||
page = 0,
|
||||
itemsPerPage = 10,
|
||||
db: Database = App.main.db,
|
||||
): Promise<SearchResult<T>> {
|
||||
): Promise<SearchResult<InstanceType<T>>> {
|
||||
const total = await this.count(query, db)
|
||||
const start = page * itemsPerPage
|
||||
const results = total > 0
|
||||
const results: T[] = total > 0
|
||||
? await query(this.table(db)).offset(start).limit(itemsPerPage)
|
||||
: []
|
||||
const end = Math.min(start + itemsPerPage, start + results.length)
|
||||
|
@ -232,7 +232,7 @@ export default class Model {
|
|||
): Promise<InstanceType<T>> {
|
||||
const formattedData = this.formatJson(data)
|
||||
const id: number = await this.table(db).insert(formattedData)
|
||||
return await this.find(id) as InstanceType<T>
|
||||
return await this.find(id, b => b, db) as InstanceType<T>
|
||||
}
|
||||
|
||||
static async update<T extends typeof Model>(
|
||||
|
@ -253,7 +253,7 @@ export default class Model {
|
|||
): Promise<InstanceType<T>> {
|
||||
const formattedData = this.formatJson(data)
|
||||
await this.table(db).where('id', id).update(formattedData)
|
||||
return await this.find(id) as InstanceType<T>
|
||||
return await this.find(id, b => b, db) as InstanceType<T>
|
||||
}
|
||||
|
||||
static async delete<T extends typeof Model>(
|
||||
|
|
|
@ -5,6 +5,7 @@ export interface SearchParams {
|
|||
itemsPerPage: number
|
||||
q?: string
|
||||
sort?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export const searchParamsSchema: JSONSchemaType<SearchParams> = {
|
||||
|
@ -30,5 +31,12 @@ export const searchParamsSchema: JSONSchemaType<SearchParams> = {
|
|||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
tags: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import Model, { ModelParams } from '../core/Model'
|
||||
import { JourneyStep } from './JourneyStep'
|
||||
|
||||
export default class Journey extends Model {
|
||||
name!: string
|
||||
|
@ -7,8 +6,8 @@ export default class Journey extends Model {
|
|||
description?: string
|
||||
deleted_at?: Date
|
||||
|
||||
steps: JourneyStep[] = []
|
||||
static virtualAttributes: string[] = ['steps']
|
||||
}
|
||||
|
||||
export type JourneyParams = Omit<Journey, ModelParams | 'steps' | 'deleted_at'>
|
||||
export type JourneyParams = Omit<Journey, ModelParams | 'deleted_at'>
|
||||
export type UpdateJourneyParams = Omit<JourneyParams, 'project_id' | 'deleted_at'>
|
||||
|
|
|
@ -3,9 +3,9 @@ import { ProjectState } from '../auth/AuthMiddleware'
|
|||
import { searchParamsSchema } from '../core/searchParams'
|
||||
import { JSONSchemaType, validate } from '../core/validate'
|
||||
import { extractQueryParams } from '../utilities'
|
||||
import Journey, { JourneyParams, UpdateJourneyParams } from './Journey'
|
||||
import { createJourney, createJourneyStep, deleteJourney, deleteJourneyStep, getJourney, pagedJourneys, updateJourney, updateJourneyStep } from './JourneyRepository'
|
||||
import { JourneyStepParams } from './JourneyStep'
|
||||
import Journey, { JourneyParams } from './Journey'
|
||||
import { createJourney, deleteJourney, getJourneyStepMap, getJourney, pagedJourneys, setJourneyStepMap, updateJourney } from './JourneyRepository'
|
||||
import { JourneyStepMap, journeyStepTypes } from './JourneyStep'
|
||||
|
||||
const router = new Router<
|
||||
ProjectState & { journey?: Journey }
|
||||
|
@ -41,7 +41,7 @@ router.post('/', async ctx => {
|
|||
|
||||
router.param('journeyId', async (value, ctx, next) => {
|
||||
ctx.state.journey = await getJourney(parseInt(value), ctx.state.project.id)
|
||||
if (!ctx.state.list) {
|
||||
if (!ctx.state.journey) {
|
||||
ctx.throw(404)
|
||||
return
|
||||
}
|
||||
|
@ -52,25 +52,8 @@ router.get('/:journeyId', async ctx => {
|
|||
ctx.body = ctx.state.journey
|
||||
})
|
||||
|
||||
const updateJourneyParams: JSONSchemaType<UpdateJourneyParams> = {
|
||||
$id: 'updateJourneyParams',
|
||||
type: 'object',
|
||||
required: ['name'],
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
}
|
||||
|
||||
router.patch('/:journeyId', async ctx => {
|
||||
const payload = validate(updateJourneyParams, ctx.request.body)
|
||||
ctx.body = await updateJourney(ctx.state.journey!.id, payload)
|
||||
ctx.body = await updateJourney(ctx.state.journey!.id, validate(journeyParams, ctx.request.body))
|
||||
})
|
||||
|
||||
router.delete('/:journeyId', async ctx => {
|
||||
|
@ -78,47 +61,58 @@ router.delete('/:journeyId', async ctx => {
|
|||
ctx.body = true
|
||||
})
|
||||
|
||||
const journeyStepParams: JSONSchemaType<JourneyStepParams> = {
|
||||
$id: 'journeyStepParams',
|
||||
const journeyStepsParamsSchema: JSONSchemaType<JourneyStepMap> = {
|
||||
$id: 'journeyStepsParams',
|
||||
type: 'object',
|
||||
required: ['type'],
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['entrance', 'delay', 'action', 'gate', 'map'],
|
||||
},
|
||||
child_id: {
|
||||
type: 'integer',
|
||||
nullable: true,
|
||||
},
|
||||
data: {
|
||||
type: 'object', // TODO: Could validate further based on sub types
|
||||
nullable: true,
|
||||
additionalProperties: true,
|
||||
},
|
||||
x: {
|
||||
type: 'integer',
|
||||
},
|
||||
y: {
|
||||
type: 'integer',
|
||||
required: [],
|
||||
additionalProperties: {
|
||||
type: 'object',
|
||||
required: ['type', 'x', 'y'],
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: Object.keys(journeyStepTypes),
|
||||
},
|
||||
data: {
|
||||
type: 'object', // TODO: Could validate further based on sub types
|
||||
nullable: true,
|
||||
additionalProperties: true,
|
||||
},
|
||||
x: {
|
||||
type: 'number',
|
||||
},
|
||||
y: {
|
||||
type: 'number',
|
||||
},
|
||||
children: {
|
||||
type: 'array',
|
||||
nullable: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['uuid'],
|
||||
properties: {
|
||||
uuid: {
|
||||
type: 'string',
|
||||
},
|
||||
data: {
|
||||
type: 'object', // TODO: this is also specific to the parent node's type
|
||||
nullable: true,
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
additionalProperties: false,
|
||||
}
|
||||
|
||||
router.post('/:journeyId/steps', async ctx => {
|
||||
const payload = validate(journeyStepParams, ctx.request.body)
|
||||
ctx.body = await createJourneyStep(ctx.state.journey!.id, payload)
|
||||
router.get('/:journeyId/steps', async ctx => {
|
||||
ctx.body = await getJourneyStepMap(ctx.state.journey!.id)
|
||||
})
|
||||
|
||||
router.patch('/:journeyId/steps/:stepId', async ctx => {
|
||||
const payload = validate(journeyStepParams, ctx.request.body)
|
||||
ctx.body = await updateJourneyStep(parseInt(ctx.params.id), payload)
|
||||
})
|
||||
|
||||
router.delete('/:journeyId/steps/:stepId', async ctx => {
|
||||
await deleteJourneyStep(parseInt(ctx.params.stepId))
|
||||
ctx.body = true
|
||||
router.put('/:journeyId/steps', async ctx => {
|
||||
ctx.body = await setJourneyStepMap(ctx.state.journey!.id, validate(journeyStepsParamsSchema, ctx.request.body))
|
||||
})
|
||||
|
||||
export default router
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import App from '../app'
|
||||
import { Database } from 'config/database'
|
||||
import { RequestError } from '../core/errors'
|
||||
import { SearchParams } from '../core/searchParams'
|
||||
import Journey, { JourneyParams, UpdateJourneyParams } from './Journey'
|
||||
import { JourneyStep, JourneyEntrance, JourneyUserStep, JourneyStepParams } from './JourneyStep'
|
||||
import { JourneyStep, JourneyEntrance, JourneyUserStep, JourneyStepMap, toJourneyStepMap, JourneyStepChild } from './JourneyStep'
|
||||
|
||||
export const pagedJourneys = async (params: SearchParams, projectId: number) => {
|
||||
return await Journey.searchParams(
|
||||
|
@ -16,20 +18,23 @@ export const allJourneys = async (projectId: number): Promise<Journey[]> => {
|
|||
}
|
||||
|
||||
export const createJourney = async (projectId: number, params: JourneyParams): Promise<Journey> => {
|
||||
return await Journey.insertAndFetch({
|
||||
...params,
|
||||
project_id: projectId,
|
||||
})
|
||||
return App.main.db.transaction(async trx => {
|
||||
|
||||
// TODO: Should we create an entrance automatically here?
|
||||
const journey = await Journey.insertAndFetch({
|
||||
...params,
|
||||
project_id: projectId,
|
||||
}, trx)
|
||||
|
||||
// auto-create entrance step
|
||||
await JourneyEntrance.create(journey.id)
|
||||
|
||||
return journey
|
||||
})
|
||||
}
|
||||
|
||||
export const getJourney = async (id: number, projectId: number): Promise<Journey> => {
|
||||
const journey = await Journey.find(id, qb => qb.where('project_id', projectId))
|
||||
if (!journey) throw new RequestError('Journey not found', 404)
|
||||
|
||||
journey.steps = await allJourneySteps(journey.id)
|
||||
|
||||
return journey
|
||||
}
|
||||
|
||||
|
@ -41,8 +46,111 @@ export const deleteJourney = async (id: number): Promise<void> => {
|
|||
await Journey.updateAndFetch(id, { deleted_at: new Date() })
|
||||
}
|
||||
|
||||
export const allJourneySteps = async (journeyId: number): Promise<JourneyStep[]> => {
|
||||
return await JourneyStep.all(qb => qb.where('journey_id', journeyId))
|
||||
export const getJourneySteps = async (journeyId: number, db?: Database): Promise<JourneyStep[]> => {
|
||||
return await JourneyStep.all(qb => qb.where('journey_id', journeyId), db)
|
||||
}
|
||||
|
||||
export const getJourneyStepChildren = async (stepId: number) => {
|
||||
return await JourneyStepChild.all(q => q.where('step_id', stepId))
|
||||
}
|
||||
|
||||
const getAllJourneyStepChildren = async (journeyId: number, db?: Database): Promise<JourneyStepChild[]> => {
|
||||
return await JourneyStepChild.all(
|
||||
q => q.whereIn('step_id', JourneyStep.query(db).select('id').where('journey_id', journeyId)),
|
||||
db,
|
||||
)
|
||||
}
|
||||
|
||||
export const getJourneyStepMap = async (journeyId: number) => {
|
||||
const [steps, children] = await Promise.all([
|
||||
getJourneySteps(journeyId),
|
||||
getAllJourneyStepChildren(journeyId),
|
||||
])
|
||||
return toJourneyStepMap(steps, children)
|
||||
}
|
||||
|
||||
export const setJourneyStepMap = async (journeyId: number, stepMap: JourneyStepMap) => {
|
||||
return await App.main.db.transaction(async trx => {
|
||||
|
||||
const [steps, children] = await Promise.all([
|
||||
getJourneySteps(journeyId, trx),
|
||||
getAllJourneyStepChildren(journeyId, trx),
|
||||
])
|
||||
|
||||
// Create or update steps
|
||||
for (const [uuid, { type, x = 0, y = 0, data = {} }] of Object.entries(stepMap)) {
|
||||
const idx = steps.findIndex(s => s.uuid === uuid)
|
||||
if (idx === -1) {
|
||||
steps.push(await JourneyStep.insertAndFetch({
|
||||
journey_id: journeyId,
|
||||
type,
|
||||
uuid,
|
||||
data,
|
||||
x,
|
||||
y,
|
||||
}, trx))
|
||||
} else {
|
||||
const step = steps[idx]
|
||||
steps[idx] = await JourneyStep.updateAndFetch(step.id, {
|
||||
type,
|
||||
uuid,
|
||||
data,
|
||||
x,
|
||||
y,
|
||||
}, trx)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete removed or unused steps
|
||||
let i = 0
|
||||
while (i < steps.length) {
|
||||
const step = steps[i]
|
||||
if (!stepMap[step.uuid]) {
|
||||
await JourneyStep.delete(q => q.where('id', step.id), trx)
|
||||
steps.splice(i, 1)
|
||||
continue
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
for (const step of steps) {
|
||||
const list = stepMap[step.uuid]?.children ?? []
|
||||
const childIds: number[] = []
|
||||
|
||||
for (const { uuid, data = {} } of list) {
|
||||
const child = steps.find(s => s.uuid === uuid)
|
||||
if (!child) continue
|
||||
const idx = children.findIndex(sc => sc.step_id === step.id && sc.child_id === child.id)
|
||||
let stepChild: JourneyStepChild
|
||||
if (idx === -1) {
|
||||
children.push(stepChild = await JourneyStepChild.insertAndFetch({
|
||||
step_id: step.id,
|
||||
child_id: child.id,
|
||||
data,
|
||||
}, trx))
|
||||
} else {
|
||||
stepChild = children[idx]
|
||||
children[idx] = await JourneyStepChild.updateAndFetch(stepChild.id, {
|
||||
data,
|
||||
}, trx)
|
||||
}
|
||||
childIds.push(stepChild.child_id)
|
||||
}
|
||||
|
||||
i = 0
|
||||
while (i < children.length) {
|
||||
const stepChild = children[i]
|
||||
if (stepChild.step_id === step.id && !childIds.includes(stepChild.child_id)) {
|
||||
await JourneyStepChild.delete(q => q.where('id', stepChild.id), trx)
|
||||
children.splice(i, 1)
|
||||
continue
|
||||
}
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
return toJourneyStepMap(steps, children)
|
||||
})
|
||||
}
|
||||
|
||||
export const getJourneyStep = async (id?: number): Promise<JourneyStep | undefined> => {
|
||||
|
@ -50,18 +158,6 @@ export const getJourneyStep = async (id?: number): Promise<JourneyStep | undefin
|
|||
return await JourneyStep.first(db => db.where('id', id))
|
||||
}
|
||||
|
||||
export const createJourneyStep = async (journeyId: number, params: JourneyStepParams): Promise<JourneyStep> => {
|
||||
return await JourneyStep.insertAndFetch({ ...params, journey_id: journeyId })
|
||||
}
|
||||
|
||||
export const updateJourneyStep = async (id: number, params: JourneyStepParams): Promise<JourneyStep> => {
|
||||
return await JourneyStep.updateAndFetch(id, { ...params })
|
||||
}
|
||||
|
||||
export const deleteJourneyStep = async (id: number): Promise<number> => {
|
||||
return await JourneyStep.delete(qb => qb.where('id', id))
|
||||
}
|
||||
|
||||
export const getUserJourneyIds = async (userId: number): Promise<number[]> => {
|
||||
return await JourneyUserStep.all(
|
||||
db => db.where('user_id', userId)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { User } from '../users/User'
|
||||
import { getJourneyEntrance, getJourneyStep, getUserJourneyIds, lastJourneyStep } from './JourneyRepository'
|
||||
import { JourneyEntrance, JourneyDelay, JourneyGate, JourneyStep, JourneyMap, JourneyAction } from './JourneyStep'
|
||||
import { JourneyEntrance, JourneyStep, journeyStepTypes } from './JourneyStep'
|
||||
import { UserEvent } from '../users/UserEvent'
|
||||
import List from '../lists/List'
|
||||
|
||||
|
@ -42,19 +42,26 @@ export default class JourneyService {
|
|||
|
||||
async run(user: User, event?: UserEvent): Promise<void> {
|
||||
|
||||
const processed: number[] = []
|
||||
|
||||
// Loop through all possible next steps until we get an empty next
|
||||
// which signifies that the journey is in a pending state
|
||||
|
||||
let nextStep: JourneyStep | undefined | null = await this.nextStep(user)
|
||||
while (nextStep) {
|
||||
const parsedStep = this.parse(nextStep)
|
||||
if (processed.includes(nextStep.id)) {
|
||||
// Avoid infinite loop in single run
|
||||
break
|
||||
}
|
||||
processed.push(nextStep.id)
|
||||
nextStep = this.parse(nextStep)
|
||||
|
||||
// If completed, jump to next otherwise validate condition
|
||||
if (await parsedStep.hasCompleted(user)) {
|
||||
nextStep = await parsedStep.next(user)
|
||||
} else if (await parsedStep.condition(user, event)) {
|
||||
await parsedStep.complete(user)
|
||||
nextStep = await parsedStep.next(user)
|
||||
if (await nextStep.hasCompleted(user)) {
|
||||
nextStep = await nextStep.next(user)
|
||||
} else if (await nextStep.condition(user, event)) {
|
||||
await nextStep.complete(user)
|
||||
nextStep = await nextStep.next(user)
|
||||
} else {
|
||||
nextStep = null
|
||||
}
|
||||
|
@ -62,14 +69,7 @@ export default class JourneyService {
|
|||
}
|
||||
|
||||
parse(step: JourneyStep): JourneyStep {
|
||||
const options = {
|
||||
[JourneyEntrance.type]: JourneyEntrance,
|
||||
[JourneyDelay.type]: JourneyDelay,
|
||||
[JourneyGate.type]: JourneyGate,
|
||||
[JourneyMap.type]: JourneyMap,
|
||||
[JourneyAction.type]: JourneyAction,
|
||||
}
|
||||
return options[step.type]?.fromJson(step)
|
||||
return journeyStepTypes[step.type]?.fromJson(step)
|
||||
}
|
||||
|
||||
async nextStep(user: User): Promise<JourneyStep | undefined> {
|
||||
|
|
|
@ -3,10 +3,12 @@ import Model from '../core/Model'
|
|||
import { User } from '../users/User'
|
||||
import Rule from '../rules/Rule'
|
||||
import { check } from '../rules/RuleEngine'
|
||||
import { getJourneyStep, getUserJourneyStep } from './JourneyRepository'
|
||||
import { getJourneyStep, getJourneyStepChildren, getUserJourneyStep } from './JourneyRepository'
|
||||
import { UserEvent } from '../users/UserEvent'
|
||||
import { getCampaign, sendCampaign } from '../campaigns/CampaignService'
|
||||
import { snakeCase } from '../utilities'
|
||||
import { random, snakeCase, uuid } from '../utilities'
|
||||
import App from '../app'
|
||||
import JourneyProcessJob from './JourneyProcessJob'
|
||||
|
||||
export class JourneyUserStep extends Model {
|
||||
user_id!: number
|
||||
|
@ -17,11 +19,22 @@ export class JourneyUserStep extends Model {
|
|||
static tableName = 'journey_user_step'
|
||||
}
|
||||
|
||||
export class JourneyStepChild extends Model {
|
||||
|
||||
step_id!: number
|
||||
child_id!: number
|
||||
data?: Record<string, unknown>
|
||||
|
||||
static tableName = 'journey_step_child'
|
||||
static jsonAttributes: string[] = ['data']
|
||||
|
||||
}
|
||||
|
||||
export class JourneyStep extends Model {
|
||||
type!: string
|
||||
journey_id!: number
|
||||
child_id?: number // the step that comes after
|
||||
data?: Record<string, unknown>
|
||||
uuid!: string
|
||||
|
||||
// UI variables
|
||||
x = 0
|
||||
|
@ -63,12 +76,12 @@ export class JourneyStep extends Model {
|
|||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async next(user: User, event?: UserEvent): Promise<JourneyStep | undefined> {
|
||||
return await getJourneyStep(this.child_id)
|
||||
const child = await JourneyStepChild.first(q => q.where('step_id', this.id))
|
||||
if (!child) return undefined
|
||||
return await getJourneyStep(child.child_id)
|
||||
}
|
||||
}
|
||||
|
||||
export type JourneyStepParams = Pick<JourneyStep, 'type' | 'child_id' | 'data' | 'x' | 'y'>
|
||||
|
||||
export class JourneyEntrance extends JourneyStep {
|
||||
static type = 'entrance'
|
||||
|
||||
|
@ -79,14 +92,16 @@ export class JourneyEntrance extends JourneyStep {
|
|||
this.list_id = json?.data.list_id
|
||||
}
|
||||
|
||||
static async create(listId: number, childId?: number, journeyId?: number): Promise<JourneyEntrance> {
|
||||
static async create(journeyId: number, listId?: number): Promise<JourneyEntrance> {
|
||||
return await JourneyEntrance.insertAndFetch({
|
||||
type: this.type,
|
||||
child_id: childId,
|
||||
uuid: uuid(),
|
||||
journey_id: journeyId,
|
||||
data: {
|
||||
list_id: listId,
|
||||
},
|
||||
x: 0,
|
||||
y: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -155,23 +170,19 @@ export class JourneyAction extends JourneyStep {
|
|||
export class JourneyGate extends JourneyStep {
|
||||
static type = 'gate'
|
||||
|
||||
entrance_type!: 'user' | 'event'
|
||||
rule!: Rule
|
||||
|
||||
parseJson(json: any) {
|
||||
super.parseJson(json)
|
||||
|
||||
this.entrance_type = json?.data?.entrance_type
|
||||
this.rule = json?.data.rule
|
||||
}
|
||||
|
||||
static async create(entranceType: string, rule: Rule, childId?: number, journeyId?: number): Promise<JourneyGate> {
|
||||
static async create(rule: Rule, journeyId?: number): Promise<JourneyGate> {
|
||||
return await JourneyGate.insertAndFetch({
|
||||
type: this.type,
|
||||
child_id: childId,
|
||||
journey_id: journeyId,
|
||||
data: {
|
||||
entrance_type: entranceType,
|
||||
rule,
|
||||
},
|
||||
})
|
||||
|
@ -191,13 +202,11 @@ export class JourneyMap extends JourneyStep {
|
|||
static type = 'map'
|
||||
|
||||
attribute!: string
|
||||
options!: MapOptions
|
||||
|
||||
parseJson(json: any) {
|
||||
super.parseJson(json)
|
||||
|
||||
this.attribute = json?.data?.attribute
|
||||
this.options = json?.data.options
|
||||
}
|
||||
|
||||
static async create(attribute: string, options: MapOptions, journeyId?: number): Promise<JourneyMap> {
|
||||
|
@ -217,13 +226,138 @@ export class JourneyMap extends JourneyStep {
|
|||
|
||||
async next(user: User) {
|
||||
|
||||
const children = await getJourneyStepChildren(this.id)
|
||||
|
||||
// When comparing the user expects comparison
|
||||
// to be between the UI model user vs core user
|
||||
const templateUser = user.flatten()
|
||||
|
||||
// Based on an attribute match, pick a child step
|
||||
const value = templateUser[this.attribute]
|
||||
const childId = this.options[value]
|
||||
return await getJourneyStep(childId)
|
||||
for (const { child_id, data = {} } of children) {
|
||||
if (data.value === value) {
|
||||
return await getJourneyStep(child_id)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback
|
||||
for (const { child_id, data } of children) {
|
||||
if (data?.fallback) {
|
||||
return await getJourneyStep(child_id)
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* randomly distribute users to different branches
|
||||
*/
|
||||
export class JourneyExperiment extends JourneyStep {
|
||||
static type = 'experiment'
|
||||
|
||||
async next() {
|
||||
|
||||
let children = await getJourneyStepChildren(this.id)
|
||||
|
||||
if (!children.length) return undefined
|
||||
|
||||
children = children.reduce<JourneyStepChild[]>((a, c) => {
|
||||
const proportion = Number(c.data?.value)
|
||||
if (!isNaN(proportion) && proportion > 0) {
|
||||
for (let i = 0; i < proportion; i++) {
|
||||
a.push(c)
|
||||
}
|
||||
}
|
||||
return a
|
||||
}, [])
|
||||
|
||||
if (!children) return undefined
|
||||
|
||||
return await getJourneyStep(random(children).child_id)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* add user to another journey
|
||||
*/
|
||||
export class JourneyLink extends JourneyStep {
|
||||
static type = 'link'
|
||||
|
||||
target_id!: number
|
||||
|
||||
parseJson(json: any) {
|
||||
super.parseJson(json)
|
||||
this.target_id = json.data?.journey_id
|
||||
}
|
||||
|
||||
async complete(user: User, event?: UserEvent | undefined): Promise<void> {
|
||||
|
||||
if (!isNaN(this.journey_id)) {
|
||||
await App.main.queue.enqueue(JourneyProcessJob.from({
|
||||
journey_id: this.target_id,
|
||||
user_id: user.id,
|
||||
event_id: event?.id,
|
||||
}))
|
||||
}
|
||||
|
||||
return super.complete(user, event)
|
||||
}
|
||||
}
|
||||
|
||||
export const journeyStepTypes = [
|
||||
JourneyEntrance,
|
||||
JourneyDelay,
|
||||
JourneyAction,
|
||||
JourneyGate,
|
||||
JourneyMap,
|
||||
JourneyExperiment,
|
||||
JourneyLink,
|
||||
].reduce<Record<string, typeof JourneyStep>>((a, c) => {
|
||||
a[c.type] = c
|
||||
return a
|
||||
}, {})
|
||||
|
||||
export type JourneyStepMap = Record<
|
||||
string,
|
||||
{
|
||||
type: string
|
||||
data?: Record<string, unknown>
|
||||
x: number
|
||||
y: number
|
||||
children?: Array<{
|
||||
uuid: string
|
||||
data?: Record<string, unknown>
|
||||
}>
|
||||
}
|
||||
>
|
||||
|
||||
// This is async in case we ever want to fetch stats here
|
||||
export async function toJourneyStepMap(steps: JourneyStep[], children: JourneyStepChild[]) {
|
||||
const editData: JourneyStepMap = {}
|
||||
|
||||
for (const step of steps) {
|
||||
editData[step.uuid] = {
|
||||
type: step.type,
|
||||
data: step.data ?? {},
|
||||
x: step.x ?? 0,
|
||||
y: step.y ?? 0,
|
||||
children: children.reduce<JourneyStepMap[string]['children']>((a, { step_id, child_id, data }) => {
|
||||
if (step_id === step.id) {
|
||||
const child = steps.find(s => s.id === child_id)
|
||||
if (child) {
|
||||
a!.push({
|
||||
uuid: child.uuid,
|
||||
data,
|
||||
})
|
||||
}
|
||||
}
|
||||
return a
|
||||
}, []),
|
||||
}
|
||||
}
|
||||
|
||||
return editData
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import { User } from '../../users/User'
|
|||
import Journey from '../Journey'
|
||||
import { lastJourneyStep } from '../JourneyRepository'
|
||||
import JourneyService from '../JourneyService'
|
||||
import { JourneyEntrance, JourneyMap } from '../JourneyStep'
|
||||
import { JourneyEntrance, JourneyMap, JourneyStepChild } from '../JourneyStep'
|
||||
|
||||
describe('Run', () => {
|
||||
describe('step progression', () => {
|
||||
|
@ -26,7 +26,12 @@ describe('Run', () => {
|
|||
US: 43,
|
||||
ES: 34,
|
||||
}, journey.id)
|
||||
await JourneyEntrance.create(list.id, step2.id, journey.id)
|
||||
const entrance = await JourneyEntrance.create(journey.id, list.id)
|
||||
await JourneyStepChild.insert({
|
||||
step_id: entrance.id,
|
||||
child_id: step2.id,
|
||||
data: {},
|
||||
})
|
||||
|
||||
const service = new JourneyService(journey.id)
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { createProject } from '../../projects/ProjectService'
|
||||
import Rule from '../../rules/Rule'
|
||||
import { User } from '../../users/User'
|
||||
import { JourneyStep, JourneyMap, JourneyGate } from '../JourneyStep'
|
||||
import { JourneyStep, JourneyMap, JourneyGate, JourneyStepChild } from '../JourneyStep'
|
||||
|
||||
describe('JourneyGate', () => {
|
||||
|
||||
|
@ -13,7 +13,7 @@ describe('JourneyGate', () => {
|
|||
|
||||
describe('user gate', () => {
|
||||
const createGate = async (rule: Rule) => {
|
||||
return await JourneyGate.create('user', rule)
|
||||
return await JourneyGate.create(rule)
|
||||
}
|
||||
|
||||
test('condition passes with valid equals rule', async () => {
|
||||
|
@ -80,7 +80,7 @@ describe('JourneyGate', () => {
|
|||
describe('Journey Map', () => {
|
||||
test('different options pick different paths', async () => {
|
||||
|
||||
const user = User.fromJson({ data: { progress: '20' } })
|
||||
const user = User.fromJson({ data: { progress: 20 } })
|
||||
const step1 = await JourneyStep.insertAndFetch()
|
||||
const step2 = await JourneyStep.insertAndFetch()
|
||||
const step3 = await JourneyStep.insertAndFetch()
|
||||
|
@ -90,10 +90,21 @@ describe('Journey Map', () => {
|
|||
20: step2.id,
|
||||
30: step3.id,
|
||||
})
|
||||
|
||||
await Promise.all(([
|
||||
[10, step1],
|
||||
[20, step2],
|
||||
[30, step3],
|
||||
] as const).map(([value, { id: child_id }]) => JourneyStepChild.insert({
|
||||
step_id: map.id,
|
||||
child_id,
|
||||
data: { value },
|
||||
})))
|
||||
|
||||
const value1 = await map.next(user)
|
||||
expect(value1?.id).toEqual(step2.id)
|
||||
|
||||
user.data.progress = '30'
|
||||
user.data.progress = 30
|
||||
const value2 = await map.next(user)
|
||||
expect(value2?.id).toEqual(step3.id)
|
||||
})
|
||||
|
|
14
src/tags/Tag.ts
Normal file
14
src/tags/Tag.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import Model, { ModelParams } from '../core/Model'
|
||||
|
||||
export class Tag extends Model {
|
||||
project_id!: number
|
||||
name!: string
|
||||
}
|
||||
|
||||
export class EntityTag extends Model {
|
||||
entity!: string // table name
|
||||
entity_id!: number
|
||||
tag_id!: number
|
||||
}
|
||||
|
||||
export type TagParams = Omit<Tag, ModelParams | 'project_id'>
|
62
src/tags/TagController.ts
Normal file
62
src/tags/TagController.ts
Normal file
|
@ -0,0 +1,62 @@
|
|||
import Router from '@koa/router'
|
||||
import { JSONSchemaType } from 'ajv'
|
||||
import { searchParamsSchema } from '../core/searchParams'
|
||||
import { validate } from '../core/validate'
|
||||
import { extractQueryParams } from '../utilities'
|
||||
import { ProjectState } from '../auth/AuthMiddleware'
|
||||
import { Tag, TagParams } from './Tag'
|
||||
|
||||
const router = new Router<
|
||||
ProjectState & {
|
||||
tag?: Tag
|
||||
}
|
||||
>({
|
||||
prefix: '/tags',
|
||||
})
|
||||
|
||||
router.get('/', async ctx => {
|
||||
ctx.body = await Tag.searchParams(
|
||||
extractQueryParams(ctx.request.query, searchParamsSchema),
|
||||
['name'],
|
||||
)
|
||||
})
|
||||
|
||||
const tagParams: JSONSchemaType<TagParams> = {
|
||||
type: 'object',
|
||||
required: ['name'],
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
router.post('/', async ctx => {
|
||||
ctx.body = await Tag.insertAndFetch(validate(tagParams, ctx.request.body))
|
||||
})
|
||||
|
||||
router.param('tagId', async (value, ctx, next) => {
|
||||
ctx.state.tag = await Tag.first(b => b.where({
|
||||
project_id: ctx.state.project.id,
|
||||
id: value,
|
||||
}))
|
||||
if (!ctx.state.tag) {
|
||||
return ctx.throw(404)
|
||||
}
|
||||
return await next()
|
||||
})
|
||||
|
||||
router.get('/:tagId', async ctx => {
|
||||
ctx.body = ctx.state.tag!
|
||||
})
|
||||
|
||||
router.patch('/:tagId', async ctx => {
|
||||
ctx.body = await Tag.updateAndFetch(ctx.state.tag!.id, validate(tagParams, ctx.request.body))
|
||||
})
|
||||
|
||||
router.delete('/:tagId', async ctx => {
|
||||
await Tag.delete(b => b.where('id', ctx.state.tag!.id))
|
||||
ctx.body = true
|
||||
})
|
||||
|
||||
export default router
|
67
src/tags/TagService.ts
Normal file
67
src/tags/TagService.ts
Normal file
|
@ -0,0 +1,67 @@
|
|||
import Model from 'core/Model'
|
||||
import { EntityTag, Tag } from './Tag'
|
||||
|
||||
// use transaction?
|
||||
export async function setTags<T extends Model & { project_id: number }>(target: T, names: string[]) {
|
||||
|
||||
// is there a better way to do this?
|
||||
const tableName = (Object.getPrototypeOf(target) as typeof Model).tableName
|
||||
const { project_id } = target
|
||||
|
||||
// if empty value passed, remove all tag relations
|
||||
if (!names?.length) {
|
||||
return await EntityTag.delete(b => b.where({
|
||||
entity: tableName,
|
||||
entity_id: target.id,
|
||||
}))
|
||||
}
|
||||
|
||||
// find/create tags in this project by name
|
||||
const tags = await Tag.all(b => b.where({
|
||||
project_id,
|
||||
names,
|
||||
}))
|
||||
|
||||
for (const name of names) {
|
||||
if (!tags.find(t => t.name === name)) {
|
||||
tags.push(await Tag.insertAndFetch({
|
||||
project_id,
|
||||
name,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const relations = await EntityTag.all(b => b.where({
|
||||
entity: tableName,
|
||||
entity_id: target.id,
|
||||
}))
|
||||
|
||||
for (const tag of tags) {
|
||||
if (!relations.find(r => r.tag_id === tag.id)) {
|
||||
await EntityTag.insert({
|
||||
entity: tableName,
|
||||
entity_id: target.id,
|
||||
tag_id: tag.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const remove = relations.filter(r => !tags.find(t => t.id === r.tag_id)).map(r => r.id)
|
||||
if (remove.length) {
|
||||
await EntityTag.delete(b => b.whereIn('id', remove))
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
// use with knex: myQuery.whereIn('id', createTagSubquery(MyEntity, 1, ['tag 1', 'tag 2']))
|
||||
export function createTagSubquery<T extends typeof Model>(model: T, project_id: number, names: string[]) {
|
||||
return EntityTag.query()
|
||||
.select('entity_id')
|
||||
.join('tags', 'tag_id', 'tag.id')
|
||||
.whereIn('tag.name', names)
|
||||
.andWhere('tag.project_id', project_id)
|
||||
.andWhere('entity', model.tableName)
|
||||
.groupBy('tag.id')
|
||||
.having(model.raw(`count(*) > ${names.length}`))
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue