From d248c747343cc2a3341677d4fab1c9a09ff1a364 Mon Sep 17 00:00:00 2001 From: Chris Anderson Date: Sun, 15 Dec 2024 10:18:37 -0600 Subject: [PATCH] Adds duplication of lists and journeys (#581) --- .../platform/src/journey/JourneyController.ts | 6 ++- apps/platform/src/journey/JourneyService.ts | 30 ++++++++++++++- apps/platform/src/lists/ListController.ts | 6 ++- apps/platform/src/lists/ListService.ts | 38 ++++++++++++++++++- apps/platform/src/rules/RuleService.ts | 32 +++++++++++++++- apps/ui/src/api.ts | 6 +++ apps/ui/src/views/journey/Journeys.tsx | 10 ++++- apps/ui/src/views/users/ListTable.tsx | 13 ++++++- 8 files changed, 131 insertions(+), 10 deletions(-) diff --git a/apps/platform/src/journey/JourneyController.ts b/apps/platform/src/journey/JourneyController.ts index 7508c3a1..ac699e74 100644 --- a/apps/platform/src/journey/JourneyController.ts +++ b/apps/platform/src/journey/JourneyController.ts @@ -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 } @@ -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) diff --git a/apps/platform/src/journey/JourneyService.ts b/apps/platform/src/journey/JourneyService.ts index 9f06003b..f70e9caf 100644 --- a/apps/platform/src/journey/JourneyService.ts +++ b/apps/platform/src/journey/JourneyService.ts @@ -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' @@ -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) => { @@ -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 = 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) + 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) +} diff --git a/apps/platform/src/lists/ListController.ts b/apps/platform/src/lists/ListController.ts index 27af6a51..e53f89ae 100644 --- a/apps/platform/src/lists/ListController.ts +++ b/apps/platform/src/lists/ListController.ts @@ -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' @@ -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', diff --git a/apps/platform/src/lists/ListService.ts b/apps/platform/src/lists/ListService.ts index 4c1595f2..b435c4d7 100644 --- a/apps/platform/src/lists/ListService.ts +++ b/apps/platform/src/lists/ListService.ts @@ -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' @@ -511,3 +511,37 @@ export const listUserCount = async (listId: number, since?: CountRange): Promise export const updateListState = async (id: number, params: Partial>) => { return await List.updateAndFetch(id, params) } + +export const duplicateList = async (list: List) => { + const params: Partial = 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>(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 + } +} diff --git a/apps/platform/src/rules/RuleService.ts b/apps/platform/src/rules/RuleService.ts index f4dd4e6a..690e3f6d 100644 --- a/apps/platform/src/rules/RuleService.ts +++ b/apps/platform/src/rules/RuleService.ts @@ -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' @@ -315,3 +315,33 @@ export const getDateRuleTypes = async (rootId: number): Promise { + 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 = { + [rule.uuid]: newRootUuid, + } + if (rules && rules.length) { + const newRules: Partial[] = [] + 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 +} diff --git a/apps/ui/src/api.ts b/apps/ui/src/api.ts index 574f45f6..149e6929 100644 --- a/apps/ui/src/api.ts +++ b/apps/ui/src/api.ts @@ -175,6 +175,9 @@ const api = { journeys: { ...createProjectEntityPath('journeys'), + duplicate: async (projectId: number | string, journeyId: number | string) => await client + .post(`${projectUrl(projectId)}/journeys/${journeyId}/duplicate`) + .then(r => r.data), steps: { get: async (projectId: number | string, journeyId: number | string) => await client .get(`/admin/projects/${projectId}/journeys/${journeyId}/steps`) @@ -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(`${projectUrl(projectId)}/lists/${listId}/duplicate`) + .then(r => r.data), }, projectAdmins: { diff --git a/apps/ui/src/views/journey/Journeys.tsx b/apps/ui/src/views/journey/Journeys.tsx index 50a08bf2..c6d20009 100644 --- a/apps/ui/src/views/journey/Journeys.tsx +++ b/apps/ui/src/views/journey/Journeys.tsx @@ -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' @@ -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() @@ -74,6 +79,9 @@ export default function Journeys() { handleEditJourney(id)}> {t('edit')} + await handleDuplicateJourney(id)}> + {t('duplicate')} + await handleArchiveJourney(id)}> {t('archive')} diff --git a/apps/ui/src/views/users/ListTable.tsx b/apps/ui/src/views/users/ListTable.tsx index 509d1be8..bbfe0929 100644 --- a/apps/ui/src/views/users/ListTable.tsx +++ b/apps/ui/src/views/users/ListTable.tsx @@ -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 { @@ -38,12 +38,18 @@ export const ListTag = ({ state, progress }: Pick) = 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() @@ -97,6 +103,9 @@ export default function ListTable({ search, selectedRow, onSelectRow, title }: L handleOnSelectRow(item)}> {t('edit')} + await handleDuplicateList(item.id)}> + {t('duplicate')} + await handleArchiveList(item.id)}> {t('archive')}