Skip to content

Commit

Permalink
Adds duplication of lists and journeys (#581)
Browse files Browse the repository at this point in the history
  • Loading branch information
pushchris authored Dec 15, 2024
1 parent 881ffb5 commit d248c74
Show file tree
Hide file tree
Showing 8 changed files with 131 additions and 10 deletions.
6 changes: 5 additions & 1 deletion apps/platform/src/journey/JourneyController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { User } from '../users/User'
import { RequestError } from '../core/errors'
import JourneyError from './JourneyError'
import { getUserFromContext } from '../users/UserRepository'
import { triggerEntrance } from './JourneyService'
import { duplicateJourney, triggerEntrance } from './JourneyService'

const router = new Router<
ProjectState & { journey?: Journey }
Expand Down Expand Up @@ -171,6 +171,10 @@ router.put('/:journeyId/steps', async ctx => {
ctx.body = await toJourneyStepMap(steps, children)
})

router.post('/:journeyId/duplicate', async ctx => {
ctx.body = await duplicateJourney(ctx.state.journey!)
})

router.get('/:journeyId/entrances', async ctx => {
const params = extractQueryParams(ctx.query, searchParamsSchema)
ctx.body = await pagedEntrancesByJourney(ctx.state.journey!.id, params)
Expand Down
30 changes: 28 additions & 2 deletions apps/platform/src/journey/JourneyService.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { User } from '../users/User'
import { getEntranceSubsequentSteps, getJourneySteps } from './JourneyRepository'
import { JourneyEntrance, JourneyStep, JourneyUserStep } from './JourneyStep'
import { getEntranceSubsequentSteps, getJourney, getJourneyStepMap, getJourneySteps, setJourneyStepMap } from './JourneyRepository'
import { JourneyEntrance, JourneyStep, JourneyStepMap, JourneyUserStep } from './JourneyStep'
import { UserEvent } from '../users/UserEvent'
import App from '../app'
import Rule, { RuleTree } from '../rules/Rule'
Expand All @@ -10,6 +10,7 @@ import Journey, { JourneyEntranceTriggerParams } from './Journey'
import JourneyError from './JourneyError'
import { RequestError } from '../core/errors'
import EventPostJob from '../client/EventPostJob'
import { pick, uuid } from '../utilities'

export const enterJourneysFromEvent = async (event: UserEvent, user?: User) => {

Expand Down Expand Up @@ -143,3 +144,28 @@ export const triggerEntrance = async (journey: Journey, payload: JourneyEntrance
// trigger async processing
await JourneyProcessJob.from({ entrance_id }).queue()
}

export const duplicateJourney = async (journey: Journey) => {
const params: Partial<Journey> = pick(journey, ['project_id', 'name', 'description'])
params.name = `Copy of ${params.name}`
params.published = false
const newJourneyId = await Journey.insert(params)

const steps = await getJourneyStepMap(journey.id)
const newSteps: JourneyStepMap = {}
const stepKeys = Object.keys(steps)
const uuidMap = stepKeys.reduce((acc, curr) => {
acc[curr] = uuid()
return acc
}, {} as Record<string, string>)
for (const key of stepKeys) {
const step = steps[key]
newSteps[uuidMap[key]] = {
...step,
children: step.children?.map(({ external_id, ...rest }) => ({ external_id: uuidMap[external_id], ...rest })),
}
}
await setJourneyStepMap(newJourneyId, newSteps)

return await getJourney(newJourneyId, journey.project_id)
}
6 changes: 5 additions & 1 deletion apps/platform/src/lists/ListController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Router from '@koa/router'
import { JSONSchemaType, validate } from '../core/validate'
import { extractQueryParams } from '../utilities'
import List, { ListCreateParams, ListUpdateParams } from './List'
import { archiveList, createList, deleteList, getList, getListUsers, importUsersToList, pagedLists, updateList } from './ListService'
import { archiveList, createList, deleteList, duplicateList, getList, getListUsers, importUsersToList, pagedLists, updateList } from './ListService'
import { SearchSchema } from '../core/searchParams'
import { ProjectState } from '../auth/AuthMiddleware'
import parse from '../storage/FileStream'
Expand Down Expand Up @@ -180,6 +180,10 @@ router.delete('/:listId', async ctx => {
ctx.body = true
})

router.post('/:listId/duplicate', async ctx => {
ctx.body = await duplicateList(ctx.state.list!)
})

router.get('/:listId/users', async ctx => {
const searchSchema = SearchSchema('listUserSearchSchema', {
sort: 'user_list.id',
Expand Down
38 changes: 36 additions & 2 deletions apps/platform/src/lists/ListService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import ListPopulateJob from './ListPopulateJob'
import { importUsers } from '../users/UserImport'
import { FileStream } from '../storage/FileStream'
import { createTagSubquery, getTags, setTags } from '../tags/TagService'
import { Chunker } from '../utilities'
import { Chunker, pick } from '../utilities'
import { getUserEventsForRules } from '../users/UserRepository'
import { DateRuleTypes, RuleResults, RuleWithEvaluationResult, checkRules, decompileRule, fetchAndCompileRule, getDateRuleType, mergeInsertRules, splitRuleTree } from '../rules/RuleService'
import { DateRuleTypes, RuleResults, RuleWithEvaluationResult, checkRules, decompileRule, duplicateRule, fetchAndCompileRule, getDateRuleType, mergeInsertRules, splitRuleTree } from '../rules/RuleService'
import { updateCampaignSendEnrollment } from '../campaigns/CampaignService'
import { cacheDecr, cacheDel, cacheGet, cacheIncr, cacheSet } from '../config/redis'
import App from '../app'
Expand Down Expand Up @@ -511,3 +511,37 @@ export const listUserCount = async (listId: number, since?: CountRange): Promise
export const updateListState = async (id: number, params: Partial<Pick<List, 'state' | 'version' | 'users_count' | 'refreshed_at'>>) => {
return await List.updateAndFetch(id, params)
}

export const duplicateList = async (list: List) => {
const params: Partial<List> = pick(list, ['project_id', 'name', 'type', 'rule_id', 'rule', 'is_visible'])
params.name = `Copy of ${params.name}`
params.state = 'draft'
let newList = await List.insertAndFetch(params)

if (list.rule_id) {
const clonedRuleId = await duplicateRule(list.rule_id, newList.project_id)
if (clonedRuleId) newList.rule_id = clonedRuleId

newList = await List.updateAndFetch(newList.id, { rule_id: clonedRuleId })

await ListPopulateJob.from(newList.id, newList.project_id).queue()

return newList
} else {
const chunker = new Chunker<Partial<UserList>>(async entries => {
await UserList.insert(entries)
}, 100)
const stream = UserList.query()
.where('list_id', list.id)
.stream()
for await (const row of stream) {
await chunker.add({
list_id: newList.id,
user_id: row.user_id,
event_id: row.event_id,
})
}
await chunker.flush()
return newList
}
}
32 changes: 31 additions & 1 deletion apps/platform/src/rules/RuleService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ModelParams } from '../core/Model'
import Project from '../projects/Project'
import { User } from '../users/User'
import { UserEvent } from '../users/UserEvent'
import { visit } from '../utilities'
import { uuid, visit } from '../utilities'
import { dateCompile } from './DateRule'
import Rule, { RuleEvaluation, RuleTree } from './Rule'
import { check } from './RuleEngine'
Expand Down Expand Up @@ -315,3 +315,33 @@ export const getDateRuleTypes = async (rootId: number): Promise<DateRuleTypes |

return { dynamic, after, before, value }
}

export const duplicateRule = async (ruleId: number, projectId: number) => {
const rule = await fetchAndCompileRule(ruleId)
if (!rule) return

const [{ id, ...wrapper }, ...rules] = decompileRule(rule, { project_id: projectId })
const newRootUuid = uuid()
const newRootId = await Rule.insert({ ...wrapper, uuid: newRootUuid })

const uuidMap: Record<string, string> = {
[rule.uuid]: newRootUuid,
}
if (rules && rules.length) {
const newRules: Partial<Rule>[] = []
for (const { id, ...rule } of rules) {
const newUuid = uuid()
uuidMap[rule.uuid] = newUuid
newRules.push({
...rule,
uuid: newUuid,
root_uuid: newRootUuid,
parent_uuid: rule.parent_uuid
? uuidMap[rule.parent_uuid]
: undefined,
})
}
await Rule.insert(newRules)
}
return newRootId
}
6 changes: 6 additions & 0 deletions apps/ui/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,9 @@ const api = {

journeys: {
...createProjectEntityPath<Journey>('journeys'),
duplicate: async (projectId: number | string, journeyId: number | string) => await client
.post<Campaign>(`${projectUrl(projectId)}/journeys/${journeyId}/duplicate`)
.then(r => r.data),
steps: {
get: async (projectId: number | string, journeyId: number | string) => await client
.get<JourneyStepMap>(`/admin/projects/${projectId}/journeys/${journeyId}/steps`)
Expand Down Expand Up @@ -234,6 +237,9 @@ const api = {
formData.append('file', file)
await client.post(`${projectUrl(projectId)}/lists/${listId}/users`, formData)
},
duplicate: async (projectId: number | string, listId: number | string) => await client
.post<List>(`${projectUrl(projectId)}/lists/${listId}/duplicate`)
.then(r => r.data),
},

projectAdmins: {
Expand Down
10 changes: 9 additions & 1 deletion apps/ui/src/views/journey/Journeys.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import Button from '../../ui/Button'
import Modal from '../../ui/Modal'
import PageContent from '../../ui/PageContent'
import { SearchTable, useSearchTableQueryState } from '../../ui/SearchTable'
import { ArchiveIcon, EditIcon, PlusIcon } from '../../ui/icons'
import { ArchiveIcon, DuplicateIcon, EditIcon, PlusIcon } from '../../ui/icons'
import { JourneyForm } from './JourneyForm'
import { Menu, MenuItem, Tag } from '../../ui'
import { ProjectContext } from '../../contexts'
Expand All @@ -29,6 +29,11 @@ export default function Journeys() {
navigate(id.toString())
}

const handleDuplicateJourney = async (id: number) => {
const journey = await api.journeys.duplicate(project.id, id)
navigate(journey.id.toString())
}

const handleArchiveJourney = async (id: number) => {
await api.journeys.delete(project.id, id)
await state.reload()
Expand Down Expand Up @@ -74,6 +79,9 @@ export default function Journeys() {
<MenuItem onClick={() => handleEditJourney(id)}>
<EditIcon />{t('edit')}
</MenuItem>
<MenuItem onClick={async () => await handleDuplicateJourney(id)}>
<DuplicateIcon />{t('duplicate')}
</MenuItem>
<MenuItem onClick={async () => await handleArchiveJourney(id)}>
<ArchiveIcon />{t('archive')}
</MenuItem>
Expand Down
13 changes: 11 additions & 2 deletions apps/ui/src/views/users/ListTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import Tag, { TagVariant } from '../../ui/Tag'
import { snakeToTitle } from '../../utils'
import { useRoute } from '../router'
import Menu, { MenuItem } from '../../ui/Menu'
import { ArchiveIcon, EditIcon } from '../../ui/icons'
import { ArchiveIcon, DuplicateIcon, EditIcon } from '../../ui/icons'
import api from '../../api'
import { useParams } from 'react-router-dom'
import { useNavigate, useParams } from 'react-router-dom'
import { Translation, useTranslation } from 'react-i18next'

interface ListTableParams {
Expand Down Expand Up @@ -38,12 +38,18 @@ export const ListTag = ({ state, progress }: Pick<List, 'state' | 'progress'>) =
export default function ListTable({ search, selectedRow, onSelectRow, title }: ListTableParams) {
const route = useRoute()
const { t } = useTranslation()
const navigate = useNavigate()
const { projectId = '' } = useParams()

function handleOnSelectRow(list: List) {
onSelectRow ? onSelectRow(list) : route(`lists/${list.id}`)
}

const handleDuplicateList = async (id: number) => {
const list = await api.lists.duplicate(projectId, id)
navigate(list.id.toString())
}

const handleArchiveList = async (id: number) => {
await api.lists.delete(projectId, id)
await state.reload()
Expand Down Expand Up @@ -97,6 +103,9 @@ export default function ListTable({ search, selectedRow, onSelectRow, title }: L
<MenuItem onClick={() => handleOnSelectRow(item)}>
<EditIcon />{t('edit')}
</MenuItem>
<MenuItem onClick={async () => await handleDuplicateList(item.id)}>
<DuplicateIcon />{t('duplicate')}
</MenuItem>
<MenuItem onClick={async () => await handleArchiveList(item.id)}>
<ArchiveIcon />{t('archive')}
</MenuItem>
Expand Down

0 comments on commit d248c74

Please sign in to comment.