mirror of
https://fast.feibisi.com/https://github.com/parcelvoy/platform.git
synced 2025-08-28 11:46:02 +08:00
Adds duplication of lists and journeys (#581)
This commit is contained in:
parent
881ffb53c4
commit
d248c74734
8 changed files with 131 additions and 10 deletions
|
@ -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)
|
||||
|
|
|
@ -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<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)
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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<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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<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
|
||||
}
|
||||
|
|
|
@ -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`)
|
||||
|
@ -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: {
|
||||
|
|
|
@ -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() {
|
|||
<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>
|
||||
|
|
|
@ -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<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()
|
||||
|
@ -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>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue