mirror of
https://fast.feibisi.com/https://github.com/parcelvoy/platform.git
synced 2025-08-28 11:46:02 +08:00
Adds journey step type to remove a user from a journey (#501)
This commit is contained in:
parent
e91d8a87c0
commit
95c733795b
13 changed files with 179 additions and 15 deletions
|
@ -5,7 +5,7 @@ import { searchParamsSchema } from '../core/searchParams'
|
|||
import { JSONSchemaType, validate } from '../core/validate'
|
||||
import { extractQueryParams } from '../utilities'
|
||||
import Journey, { JourneyEntranceTriggerParams, JourneyParams } from './Journey'
|
||||
import { createJourney, getJourneyStepMap, getJourney, pagedJourneys, setJourneyStepMap, updateJourney, pagedEntrancesByJourney, getEntranceLog, pagedUsersByStep, archiveJourney, deleteJourney } from './JourneyRepository'
|
||||
import { createJourney, getJourneyStepMap, getJourney, pagedJourneys, setJourneyStepMap, updateJourney, pagedEntrancesByJourney, getEntranceLog, pagedUsersByStep, archiveJourney, deleteJourney, exitUserFromJourney } from './JourneyRepository'
|
||||
import { JourneyStep, JourneyStepMapParams, JourneyUserStep, journeyStepTypes, toJourneyStepMap } from './JourneyStep'
|
||||
import { User } from '../users/User'
|
||||
import { RequestError } from '../core/errors'
|
||||
|
@ -179,13 +179,7 @@ router.get('/:journeyId/entrances', async ctx => {
|
|||
router.delete('/:journeyId/entrances/:entranceId/users/:userId', async ctx => {
|
||||
const user = await getUserFromContext(ctx)
|
||||
if (!user) return ctx.throw(404)
|
||||
const results = await JourneyUserStep.update(
|
||||
q => q.where('user_id', user.id)
|
||||
.where('entrance_id', parseInt(ctx.params.entranceId))
|
||||
.whereNull('ended_at')
|
||||
.where('journey_id', ctx.state.journey!.id),
|
||||
{ ended_at: new Date() },
|
||||
)
|
||||
const results = await exitUserFromJourney(user.id, parseInt(ctx.params.entranceId), ctx.state.journey!.id)
|
||||
ctx.body = { exits: results }
|
||||
})
|
||||
|
||||
|
|
|
@ -296,3 +296,25 @@ export const getEntranceLog = async (entranceId: number) => {
|
|||
}
|
||||
return userSteps
|
||||
}
|
||||
|
||||
export const getJourneyUserStepByExternalId = async (journeyId: number, userId: number, externalId: string, db?: Database): Promise<JourneyUserStep | undefined> => {
|
||||
return await JourneyUserStep.first(
|
||||
qb => qb.leftJoin('journey_steps', 'journey_steps.id', 'journey_user_step.step_id')
|
||||
.where('journey_user_step.journey_id', journeyId)
|
||||
.where('journey_steps.external_id', externalId)
|
||||
.where('journey_user_step.user_id', userId)
|
||||
.select('journey_user_step.*'),
|
||||
db,
|
||||
)
|
||||
}
|
||||
|
||||
export const exitUserFromJourney = async (userId: number, entranceId: number, journeyId: number) => {
|
||||
console.log('exiting from', userId, entranceId, journeyId)
|
||||
await JourneyUserStep.update(
|
||||
q => q.where('user_id', userId)
|
||||
.where('id', entranceId)
|
||||
.whereNull('ended_at')
|
||||
.where('journey_id', journeyId),
|
||||
{ ended_at: new Date() },
|
||||
)
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@ import App from '../app'
|
|||
import { RRule } from 'rrule'
|
||||
import { JourneyState } from './JourneyState'
|
||||
import { EventPostJob, UserPatchJob } from '../jobs'
|
||||
import { exitUserFromJourney, getJourneyUserStepByExternalId } from './JourneyRepository'
|
||||
|
||||
export class JourneyUserStep extends Model {
|
||||
user_id!: number
|
||||
|
@ -161,6 +162,38 @@ export class JourneyEntrance extends JourneyStep {
|
|||
}
|
||||
}
|
||||
|
||||
export class JourneyExit extends JourneyStep {
|
||||
static type = 'exit'
|
||||
|
||||
entrance_uuid!: string
|
||||
event_name!: string
|
||||
rule?: Rule
|
||||
|
||||
parseJson(json: any) {
|
||||
super.parseJson(json)
|
||||
|
||||
this.entrance_uuid = json?.data?.entrance_uuid
|
||||
this.event_name = json?.data?.event_name
|
||||
this.rule = json?.data?.rule
|
||||
}
|
||||
|
||||
async process(state: JourneyState, userStep: JourneyUserStep): Promise<void> {
|
||||
const entrance = await getJourneyUserStepByExternalId(this.journey_id, userStep.user_id, this.entrance_uuid)
|
||||
if (entrance) await exitUserFromJourney(userStep.user_id, entrance.id, this.journey_id)
|
||||
|
||||
super.process(state, userStep)
|
||||
}
|
||||
|
||||
static async create(journeyId: number, db?: Database): Promise<JourneyExit> {
|
||||
return await JourneyExit.insertAndFetch({
|
||||
external_id: uuid(),
|
||||
journey_id: journeyId,
|
||||
x: 0,
|
||||
y: 0,
|
||||
}, db)
|
||||
}
|
||||
}
|
||||
|
||||
type JourneyDelayFormat = 'duration' | 'time' | 'date'
|
||||
export class JourneyDelay extends JourneyStep {
|
||||
static type = 'delay'
|
||||
|
@ -556,6 +589,7 @@ export class JourneyEvent extends JourneyStep {
|
|||
|
||||
export const journeyStepTypes = [
|
||||
JourneyEntrance,
|
||||
JourneyExit,
|
||||
JourneyDelay,
|
||||
JourneyAction,
|
||||
JourneyGate,
|
||||
|
|
|
@ -118,6 +118,10 @@
|
|||
"exclusion_lists": "Exclusion Lists",
|
||||
"existing_team_member": "Existing Team Member",
|
||||
"exit": "Exit",
|
||||
"exit_desc": "Remove users from a selected journey flow.",
|
||||
"exit_step_default": "Exit users from {{name}}",
|
||||
"exit_entrance_label": "Entrance Flow",
|
||||
"exit_entrance_desc": "Select the entrance flow to end for the user.",
|
||||
"experiment": "Experiment",
|
||||
"experiment_default": "Proportionally split users between paths.",
|
||||
"experiment_desc": "Randomly send users down different paths.",
|
||||
|
|
|
@ -118,6 +118,10 @@
|
|||
"exclusion_lists": "Listas de exclusión",
|
||||
"existing_team_member": "Miembro del equipo existente",
|
||||
"exit": "Cerrar",
|
||||
"exit_desc": "Sacar el usario del camino eligido.",
|
||||
"exit_step_default": "Sacar el usario del camino {{name}}",
|
||||
"exit_entrance_label": "Entrada del Camino",
|
||||
"exit_entrance_desc": "Selectiona la entrada que quieres terminar para el usario.",
|
||||
"experiment": "Experimento",
|
||||
"experiment_default": "Divida proporcionalmente a los usuarios entre las rutas.",
|
||||
"experiment_desc": "Envía aleatoriamente a los usuarios por diferentes rutas.",
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { ComponentType, Dispatch, Key, ReactNode, SetStateAction } from 'react'
|
||||
import { FieldPath, FieldValues, UseFormReturn } from 'react-hook-form'
|
||||
import { Node } from 'reactflow'
|
||||
|
||||
export type Class<T> = new () => T
|
||||
|
||||
|
@ -312,12 +313,12 @@ export interface JourneyStepTypeEdgeProps<T, E> extends ControlledProps<E> {
|
|||
export interface JourneyStepType<T = any, E = any> {
|
||||
name: string
|
||||
icon: ReactNode
|
||||
category: 'entrance' | 'delay' | 'flow' | 'action'
|
||||
category: 'entrance' | 'delay' | 'flow' | 'action' | 'exit'
|
||||
description: string
|
||||
Describe?: ComponentType<JourneyStepTypeEditProps<T>>
|
||||
newData?: () => Promise<T>
|
||||
newEdgeData?: () => Promise<E>
|
||||
Edit?: ComponentType<JourneyStepTypeEditProps<T>>
|
||||
Edit?: ComponentType<JourneyStepTypeEditProps<T> & { nodes: Node[] }>
|
||||
EditEdge?: ComponentType<JourneyStepTypeEdgeProps<T, E>>
|
||||
sources?: string[]
|
||||
multiChildSources?: boolean
|
||||
|
|
|
@ -84,7 +84,10 @@
|
|||
height: 16px;
|
||||
}
|
||||
|
||||
.component.entrance .component-handle, .journey-step.entrance .step-header-icon {
|
||||
.component.entrance .component-handle,
|
||||
.journey-step.entrance .step-header-icon,
|
||||
.component.exit .component-handle,
|
||||
.journey-step.exit .step-header-icon {
|
||||
background-color: var(--color-red-soft);
|
||||
color: var(--color-red-hard);
|
||||
}
|
||||
|
@ -108,11 +111,13 @@
|
|||
color: var(--color-green-hard);
|
||||
}
|
||||
|
||||
.journey-minimap.entrance {
|
||||
.journey-minimap.entrance,
|
||||
.journey-minimap.exit {
|
||||
fill: var(--color-red-soft);
|
||||
}
|
||||
|
||||
.journey-minimap.entrance.selected {
|
||||
.journey-minimap.entrance.selected,
|
||||
.journey-minimap.exit.selected {
|
||||
fill: var(--color-red);
|
||||
}
|
||||
|
||||
|
@ -182,7 +187,8 @@
|
|||
opacity: 1;
|
||||
}
|
||||
|
||||
.journey-step.entrance.selected {
|
||||
.journey-step.entrance.selected,
|
||||
.journey-step.exit.selected {
|
||||
border-color: var(--color-red-hard);
|
||||
}
|
||||
|
||||
|
@ -200,7 +206,7 @@
|
|||
|
||||
.journey-step .data-key {
|
||||
background-color: var(--color-background-soft);
|
||||
padding: 5px;
|
||||
padding: 7px 10px;
|
||||
border-radius: var(--border-radius-inner);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
|
|
@ -67,6 +67,7 @@ export const stepCategoryColors = {
|
|||
action: 'blue',
|
||||
flow: 'green',
|
||||
delay: 'yellow',
|
||||
exit: 'red',
|
||||
}
|
||||
|
||||
interface StepUsersProps {
|
||||
|
@ -686,6 +687,7 @@ export default function JourneyEditor() {
|
|||
project,
|
||||
journey,
|
||||
stepId: editNode.data.stepId,
|
||||
nodes,
|
||||
})
|
||||
}
|
||||
</div>
|
||||
|
|
59
apps/ui/src/views/journey/steps/Exit.tsx
Normal file
59
apps/ui/src/views/journey/steps/Exit.tsx
Normal file
|
@ -0,0 +1,59 @@
|
|||
import { JourneyStepType } from '../../../types'
|
||||
import { CloseIcon } from '../../../ui/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SingleSelect } from '../../../ui/form/SingleSelect'
|
||||
import { snakeToTitle } from '../../../utils'
|
||||
import { Node, useReactFlow } from 'reactflow'
|
||||
|
||||
interface ExitConfig {
|
||||
entrance_uuid?: string
|
||||
}
|
||||
|
||||
// type StepList = Array<{ id: string, label: string }>
|
||||
const entranceName = ({ data: { type, name, data_key } }: Node) => {
|
||||
const stepName = name || snakeToTitle(type)
|
||||
return data_key
|
||||
? `${stepName} (${data_key})`
|
||||
: stepName
|
||||
}
|
||||
|
||||
export const exitStep: JourneyStepType<ExitConfig> = {
|
||||
name: 'exit',
|
||||
icon: <CloseIcon />,
|
||||
category: 'exit',
|
||||
description: 'exit_desc',
|
||||
Describe({
|
||||
value,
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { getNode } = useReactFlow()
|
||||
if (!value.entrance_uuid) return <></>
|
||||
const node = getNode(value.entrance_uuid)
|
||||
if (!node) return <></>
|
||||
return (
|
||||
<div style={{ maxWidth: 300 }}>
|
||||
{t('exit_step_default', { name: entranceName(node) })}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
Edit({
|
||||
onChange,
|
||||
value,
|
||||
nodes,
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const steps = nodes
|
||||
.filter(item => item.data.type === 'entrance')
|
||||
.map((node) => ({ id: node.id, label: entranceName(node) }))
|
||||
return (
|
||||
<SingleSelect
|
||||
options={steps}
|
||||
label={t('exit_entrance_label')}
|
||||
subtitle={t('exit_entrance_desc')}
|
||||
value={value.entrance_uuid}
|
||||
onChange={(entrance_uuid) => onChange({ entrance_uuid })}
|
||||
toValue={x => x.id}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
|
||||
export { entranceStep as entrance } from './Entrance'
|
||||
export { exitStep as exit } from './Exit'
|
||||
export { actionStep as action } from './Action'
|
||||
export { delayStep as delay } from './Delay'
|
||||
export { gateStep as gate } from './Gate'
|
||||
|
|
30
docs/docs/how-to/journeys/examples/exitCriteria.md
Normal file
30
docs/docs/how-to/journeys/examples/exitCriteria.md
Normal file
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
title: Exit Criteria
|
||||
sidebar_position: 5
|
||||
---
|
||||
|
||||
# Exit Criteria
|
||||
|
||||
## Scenario
|
||||
You have a long journey for user onboarding that you want to remove a user from if they have performed a certain action. You could achieve this functionality by adding a gate before every single step to check for your exit criteria, but that can be really cumbersome and you are looking to streamline the process.
|
||||
|
||||

|
||||
|
||||
## Steps
|
||||
1. Create an `Entrance` step:
|
||||
- Set the name to `Test Entrance`
|
||||
- Event based
|
||||
- Listen for any event (e.g. `Enter Test`)
|
||||
- Multiple entries is not needed
|
||||
2. Add any set of additional steps to create your journey, for this example, add a delay step of one day.
|
||||
3. Connect the entrance and delay steps together.
|
||||
4. Add another `Entrance` step:
|
||||
- Event based
|
||||
- Listen for any criteria you want to set as "exit criteria" (e.g. `Exit Test`)
|
||||
5. Add an exit step
|
||||
- Select `Test Entrance` as the entrance you want to remove a user from
|
||||
6. Save
|
||||
|
||||
You can now run a user through the flow. When you trigger an event `Enter Test` on the user they will enter into the `Test Entrance` flow of your journey. Next if you trigger an event `Exit Test` the user will be removed from the `Test Entrance` flow with whatever step they are currently at as the latest one.
|
||||
|
||||
You can create as many different exit criterias as you want by just creating additional entrances with different criteria and adding additional exits.
|
|
@ -9,6 +9,7 @@ sidebar_position: 1
|
|||
- [**Balancer**](#balancer): Randomly split users across paths and rate limit traffic.
|
||||
- [**Delay**](#delay): Wait for a duration or until a specific date or time before proceeding.
|
||||
- [**Entrance**](#entrance): Entry point into the `Journey` that can be triggered by events, schedules, or by other `Journeys` with `Link` steps.
|
||||
- [**Exit**](#exit): A forced exit from any path a user is in for a selected entrance.
|
||||
- [**Event**](#event): Trigger an analytic event for the user.
|
||||
- [**Experiment**](#experiment): Randomly send users down multiple paths for A/B testing purposes.
|
||||
- [**Gate**](#gate): Split a user between paths depending on the result of a condition.
|
||||
|
@ -81,6 +82,12 @@ All users from a provided list will start the journey on a provided interval. Th
|
|||
- **Interval**: Given the frequency, how often should the entrance happen. A frequency of daily with an interval of two would run every other day.
|
||||
- **Days**: If set, on what days should the entrance only run on.
|
||||
|
||||
## Exit
|
||||
Sometimes you might wish to remove a user from a given journey. This can be useful for setting up things like exit criteria under which you stop a user from continuing down a journey if they perform a certain action.
|
||||
|
||||
#### Parameters
|
||||
- **Event Name**: Which event should trigger the entrance
|
||||
- **Entrance Flow**: The entrance for the flow you wish to remove the user from. This can either be the same flow you are currently in, or any flow in the given journey.
|
||||
|
||||
## Event
|
||||
If you have an external analytics provider setup (i.e. Segment) you can trigger external events using this type of step. When triggered, an event will be generated with parameters generated from the template and sent.
|
||||
|
|
BIN
docs/static/img/journeys_example_exit.png
vendored
Normal file
BIN
docs/static/img/journeys_example_exit.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 31 KiB |
Loading…
Add table
Add a link
Reference in a new issue