platform/models/task/src/migration.ts
Alexander Onnikov e3353df572
UBER-734 Rework makeRank and migrate ranks (#7104)
Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>
2024-11-05 16:25:26 +07:00

637 lines
20 KiB
TypeScript

//
// Copyright © 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import activity, { type DocUpdateMessage } from '@hcengineering/activity'
import {
DOMAIN_STATUS,
DOMAIN_TX,
TxOperations,
toIdMap,
type Attribute,
type Class,
type Doc,
type Ref,
type Space,
type Status,
type TxCollectionCUD,
type TxCreateDoc,
type TxUpdateDoc
} from '@hcengineering/core'
import {
createOrUpdate,
migrateSpace,
migrateSpaceRanks,
tryMigrate,
tryUpgrade,
type MigrateOperation,
type MigrationClient,
type MigrationUpgradeClient,
type ModelLogger
} from '@hcengineering/model'
import { DOMAIN_ACTIVITY } from '@hcengineering/model-activity'
import core, { DOMAIN_SPACE } from '@hcengineering/model-core'
import tags from '@hcengineering/model-tags'
import {
taskId,
type Project,
type ProjectStatus,
type ProjectType,
type ProjectTypeDescriptor,
type Task,
type TaskType
} from '@hcengineering/task'
import { DOMAIN_KANBAN, DOMAIN_TASK } from '.'
import task from './plugin'
/**
* @public
*/
export async function createSequence (tx: TxOperations, _class: Ref<Class<Doc>>): Promise<void> {
if ((await tx.findOne(task.class.Sequence, { attachedTo: _class })) === undefined) {
await tx.createDoc(task.class.Sequence, core.space.Workspace, {
attachedTo: _class,
sequence: 0
})
}
}
export async function migrateDefaultStatusesBase<T extends Task> (
client: MigrationClient,
logger: ModelLogger,
defaultTypeId: Ref<ProjectType>,
typeDescriptor: Ref<ProjectTypeDescriptor>,
baseClass: Ref<Class<Space>>,
defaultTaskTypeId: Ref<TaskType>,
taskTypeClass: Ref<Class<TaskType>>,
baseTaskClass: Ref<Class<T>>,
statusAttributeOf: Ref<Attribute<Status>>,
statusClass: Ref<Class<Status>>,
getDefaultStatus: (oldStatus: Status) => Ref<Status> | undefined,
migrateProjects?: (getNewStatus: (oldStatus: Ref<Status>) => Ref<Status>) => Promise<void>
): Promise<void> {
const h = client.hierarchy
const baseTaskClasses = h.getDescendants(baseTaskClass).filter((it) => !h.isMixin(it))
let counter = 0
// There are several cases possible based on the history of the workspace
// 1. One system default type - pretty fresh or already migrated workspace.
// Proceed with the regular scenario.
// 2. One custom default type (modifiedBy user or ConfigUser) - migrated system type.
// 2.a. If modified by ConfigUser - proceed with the regular scenario. Update to become modified by system.
// 2.b. If modified by user - update to use the new ID of the type.
// 3. More than one type (one system and one custom) - the tool is running after the WS upgrade.
// Not supported for now. Alternatively - Proceed with (2) scenario for the custom one. Delete it in the end.
const defaultTypes = await client.find<TxCreateDoc<ProjectType>>(DOMAIN_TX, {
_class: core.class.TxCreateDoc,
objectId: defaultTypeId,
objectSpace: core.space.Model,
'attributes.descriptor': typeDescriptor
})
if (defaultTypes.length === 2) {
logger.log('Are you running the tool after the workspace has been upgraded?', '')
logger.error('NOT SUPPORTED. EXITING.', '')
return
} else if (defaultTypes.length === 0) {
logger.log('No default type found. Was custom and already migrated? Nothing to do.', '')
return
}
const defaultType = defaultTypes[0]
logger.log('Default type', defaultType)
if (defaultType.modifiedBy !== core.account.System) {
let moveToCustom = false
if (defaultType.modifiedBy === core.account.ConfigUser) {
// Can only move to system if the task is with the system id
// and not modified by user
if (defaultType.attributes.tasks.length === 1 && defaultType.attributes.tasks[0] === defaultTaskTypeId) {
const defaultTaskType = (
await client.find<TxCreateDoc<TaskType>>(DOMAIN_TX, {
_class: core.class.TxCreateDoc,
objectId: defaultTaskTypeId,
objectSpace: core.space.Model,
'attributes.parent': defaultTypeId
})
)[0]
if (defaultTaskType?.modifiedBy === core.account.ConfigUser) {
logger.log('Moving the existing default type created by ConfigUser to a system one', '')
logger.log('Moving the existing default task type created by ConfigUser to a system one', '')
await client.update(
DOMAIN_TX,
{ _id: defaultTaskType._id },
{
$set: {
modifiedBy: core.account.System
}
}
)
await client.update(
DOMAIN_TX,
{ _id: defaultType._id },
{
$set: {
modifiedBy: core.account.System
}
}
)
} else if (defaultTaskType?.modifiedBy !== core.account.System) {
logger.log('Default task type has been modified by user.', '')
logger.error('NOT SUPPORTED. EXITING.', '')
return
}
} else {
moveToCustom = true
}
}
if (defaultType.modifiedBy !== core.account.ConfigUser || moveToCustom) {
// modified by user
// Update to use the new ID of the type if no default task type
if (defaultType.attributes.tasks.includes(defaultTaskTypeId)) {
logger.log('Default type has been modified by user and it contains default task type', '')
logger.error('NOT SUPPORTED. EXITING.', '')
return
}
logger.log('Moving the existing default type to a custom one', '')
const newId = defaultType.objectId + '-custom'
await client.update(
DOMAIN_TX,
{ _id: defaultType._id },
{
$set: {
'attributes.name': defaultType.attributes.name + ' (custom)',
objectId: newId
}
}
)
await client.update(
DOMAIN_TX,
{
objectId: defaultType.objectId,
objectSpace: core.space.Model
},
{
$set: {
objectId: newId
}
}
)
await client.update(
DOMAIN_TX,
{
objectId: { $in: defaultType.attributes.tasks },
objectSpace: core.space.Model,
'attributes.parent': defaultTypeId
},
{
$set: {
'attributes.parent': newId
}
}
)
await client.update(
DOMAIN_SPACE,
{
_class: baseClass,
type: defaultTypeId
},
{
$set: { type: newId }
}
)
}
}
const statusClasses = h.getDescendants(core.class.Status).filter((it) => !h.isMixin(it))
// Check all statuses that haven't been already migrated
// Check statuses of specific attribute
const oldStatusesSpecific = await client.find<Status>(DOMAIN_STATUS, {
_class: { $in: statusClasses },
ofAttribute: statusAttributeOf,
__superseded: { $exists: false }
})
// Also, check all statuses in the projects with generic task attribute
const projectTypes = await client.model.findAll<ProjectType>(task.class.ProjectType, {
space: core.space.Model,
descriptor: typeDescriptor
})
const projectStatuses = new Set<Ref<Status>>()
for (const pt of projectTypes) {
for (const status of pt.statuses) {
projectStatuses.add(status._id)
}
}
const oldStatusesGenericProjects = await client.find<Status>(DOMAIN_STATUS, {
_class: { $in: statusClasses },
_id: { $in: [...projectStatuses] },
ofAttribute: task.attribute.State,
__superseded: { $exists: false }
})
const oldStatuses = [...oldStatusesSpecific, ...oldStatusesGenericProjects]
// Build statuses mapping oldId -> {category, newId}
const statusMapping: Record<Ref<Status>, Ref<Status>> = {}
for (const s of oldStatuses) {
const defaultStatusId = getDefaultStatus(s)
if (defaultStatusId === undefined || defaultStatusId === s._id) {
continue
}
statusMapping[s._id] = defaultStatusId
}
logger.log('Status mapping', statusMapping)
if (Object.entries(statusMapping).length === 0) {
logger.log('All statuses have been already migrated or running on upgraded workspace', '')
return
}
const statusIdsBeingMigrated = Object.keys(statusMapping) as Ref<Status>[]
// Migration
function getNewProjectStatus (status: ProjectStatus): ProjectStatus {
const newId = statusMapping[status._id]
if (newId === undefined) {
return status
}
return { ...status, _id: newId }
}
function getNewStatus (status: Ref<Status>): Ref<Status> {
return statusMapping[status] ?? status
}
// For project types with the same descriptor
// 1. Update all create TXes with statuses
// 1. Update all update TXes with statuses
// 2. Update all push TXes with statuses
const projectTypeStatusesCreates = await client.find<TxCreateDoc<ProjectType>>(DOMAIN_TX, {
_class: core.class.TxCreateDoc,
objectClass: task.class.ProjectType,
objectSpace: core.space.Model,
'attributes.descriptor': typeDescriptor
})
logger.log('projectTypeStatusesCreates: ', projectTypeStatusesCreates.length)
counter = 0
for (const ptsCreate of projectTypeStatusesCreates) {
const newUpdateStatuses = ptsCreate.attributes.statuses?.map(getNewProjectStatus)
if (areSameArrays(newUpdateStatuses, ptsCreate.attributes.statuses)) {
continue
}
counter++
await client.update(DOMAIN_TX, { _id: ptsCreate._id }, { $set: { 'attributes.statuses': newUpdateStatuses } })
}
logger.log('projectTypeStatusesCreates updated: ', counter)
const projectTypeStatusesUpdates = await client.find<TxUpdateDoc<ProjectType>>(DOMAIN_TX, {
_class: core.class.TxUpdateDoc,
objectId: { $in: projectTypeStatusesCreates.map((sc) => sc.objectId) },
objectClass: task.class.ProjectType,
objectSpace: core.space.Model,
'operations.statuses': { $exists: true }
})
logger.log('projectTypeStatusesUpdates: ', projectTypeStatusesUpdates.length)
counter = 0
for (const ptsUpdate of projectTypeStatusesUpdates) {
const newUpdateStatuses = ptsUpdate.operations.statuses?.map(getNewProjectStatus)
if (areSameArrays(newUpdateStatuses, ptsUpdate.operations.statuses)) {
continue
}
counter++
await client.update(DOMAIN_TX, { _id: ptsUpdate._id }, { $set: { 'operations.statuses': newUpdateStatuses } })
}
logger.log('projectTypeStatusesUpdates updated: ', counter)
const projectTypeStatusesPushes = await client.find<TxUpdateDoc<ProjectType>>(DOMAIN_TX, {
_class: core.class.TxUpdateDoc,
objectId: { $in: projectTypeStatusesCreates.map((sc) => sc.objectId) },
objectClass: task.class.ProjectType,
objectSpace: core.space.Model,
'operations.$push.statuses': { $exists: true }
})
logger.log('projectTypeStatusesPushes: ', projectTypeStatusesPushes.length)
counter = 0
for (const ptsUpdate of projectTypeStatusesPushes) {
const pushedProjectStatus = ptsUpdate.operations.$push?.statuses
if (pushedProjectStatus === undefined) {
continue
}
const newPushStatus = getNewProjectStatus(pushedProjectStatus as ProjectStatus)
if (pushedProjectStatus === newPushStatus) {
continue
}
counter++
await client.update(DOMAIN_TX, { _id: ptsUpdate._id }, { $set: { 'operations.$push.statuses': newPushStatus } })
}
logger.log('projectTypeStatusesPushes updated: ', counter)
// All task types
// 1. Update create TX
// 2. Update all update TXes with statuses
const allTaskTypes = await client.find<TxCreateDoc<TaskType>>(DOMAIN_TX, {
_class: core.class.TxCreateDoc,
objectClass: taskTypeClass,
'attributes.ofClass': { $in: baseTaskClasses }
})
logger.log('allTaskTypes: ', allTaskTypes.length)
counter = 0
for (const taskType of allTaskTypes) {
const newTaskTypeStatuses = taskType.attributes.statuses.map(getNewStatus)
if (areSameArrays(newTaskTypeStatuses, taskType.attributes.statuses)) {
continue
}
counter++
await client.update(DOMAIN_TX, { _id: taskType._id }, { $set: { 'attributes.statuses': newTaskTypeStatuses } })
}
logger.log('allTaskTypes updated: ', counter)
const allTaskTypeStatusesUpdates = await client.find<TxUpdateDoc<TaskType>>(DOMAIN_TX, {
_class: core.class.TxUpdateDoc,
objectClass: taskTypeClass,
objectId: { $in: allTaskTypes.map((tt) => tt.objectId) },
'operations.statuses': { $exists: true }
})
logger.log('allTaskTypeStatusesUpdates: ', allTaskTypeStatusesUpdates.length)
counter = 0
for (const ttsUpdate of allTaskTypeStatusesUpdates) {
const newTaskTypeUpdateStatuses = ttsUpdate.operations.statuses?.map(getNewStatus)
logger.log('newTaskTypeUpdateStatuses for ' + ttsUpdate._id, newTaskTypeUpdateStatuses)
if (areSameArrays(newTaskTypeUpdateStatuses, ttsUpdate.operations.statuses)) {
logger.log('Nothing to update', '')
continue
}
counter++
await client.update(
DOMAIN_TX,
{ _id: ttsUpdate._id },
{ $set: { 'operations.statuses': newTaskTypeUpdateStatuses } }
)
}
logger.log('allTaskTypeStatusesUpdates updated: ', counter)
await migrateProjects?.(getNewStatus)
// For all Tasks:
// 1. status
// 2. TxCollectionCUD:TxCreateDoc
// 3. TxCollectionCUD:TxUpdateDoc
// 3. DocUpdateMessage:action:update&attributeUpdates:attrKey:status
const affectedBaseTasks = await client.find<Task>(DOMAIN_TASK, {
_class: { $in: baseTaskClasses },
status: { $in: statusIdsBeingMigrated }
})
logger.log('affectedBaseTasks: ', affectedBaseTasks.length)
counter = 0
for (const baseTask of affectedBaseTasks) {
const newStatus = getNewStatus(baseTask.status)
if (newStatus !== baseTask.status) {
counter++
await client.update(DOMAIN_TASK, { _id: baseTask._id }, { $set: { status: newStatus } })
}
}
logger.log('affectedBaseTasks updated: ', counter)
const baseTaskCreateTxes = await client.find<TxCollectionCUD<T, T>>(DOMAIN_TX, {
_class: core.class.TxCollectionCUD,
'tx._class': core.class.TxCreateDoc,
'tx.objectClass': { $in: baseTaskClasses },
'tx.attributes.status': { $in: statusIdsBeingMigrated }
})
logger.log('Base task create TXes: ', baseTaskCreateTxes.length)
counter = 0
for (const baseTaskCreateTx of baseTaskCreateTxes) {
const tx = baseTaskCreateTx.tx as TxCreateDoc<T>
const newStatus = getNewStatus(tx.attributes.status)
if (newStatus !== tx.attributes.status) {
counter++
await client.update(DOMAIN_TX, { _id: baseTaskCreateTx._id }, { $set: { 'tx.attributes.status': newStatus } })
}
}
logger.log('Base task create TXes updated: ', counter)
const baseTaskUpdateTxes = await client.find<TxCollectionCUD<T, T>>(DOMAIN_TX, {
_class: core.class.TxCollectionCUD,
'tx._class': core.class.TxUpdateDoc,
'tx.objectClass': { $in: baseTaskClasses },
'tx.operations.status': { $in: statusIdsBeingMigrated }
})
logger.log('Base task update TXes: ', baseTaskUpdateTxes.length)
counter = 0
for (const baseTaskUpdateTx of baseTaskUpdateTxes) {
const tx = baseTaskUpdateTx.tx as TxUpdateDoc<T>
const newStatus = tx.operations.status !== undefined ? getNewStatus(tx.operations.status) : undefined
if (newStatus !== tx.operations.status) {
counter++
await client.update(DOMAIN_TX, { _id: baseTaskUpdateTx._id }, { $set: { 'tx.operations.status': newStatus } })
}
}
logger.log('Base task update TXes updated: ', counter)
const baseTaskUpdateMessages = await client.find<DocUpdateMessage>(DOMAIN_ACTIVITY, {
_class: activity.class.DocUpdateMessage,
action: 'update',
objectClass: { $in: baseTaskClasses },
'attributeUpdates.attrKey': 'status',
'attributeUpdates.set.0.': { $in: statusIdsBeingMigrated }
})
logger.log('Base task update messages: ', baseTaskUpdateMessages.length)
counter = 0
for (const updateMessage of baseTaskUpdateMessages) {
const statusSet = updateMessage.attributeUpdates?.set[0]
const newStatusSet = statusSet != null ? getNewStatus(statusSet as Ref<Status>) : statusSet
if (statusSet !== newStatusSet) {
counter++
await client.update(
DOMAIN_ACTIVITY,
{ _id: updateMessage._id },
{ $set: { 'attributeUpdates.set.0': newStatusSet } }
)
}
}
logger.log('Base task update messages updated: ', counter)
logger.log('Updating statuses themselves:', '')
const createdStatuses = new Set<Ref<Status>>()
for (const statusIdBeingMigrated of statusIdsBeingMigrated) {
const newStatus = getNewStatus(statusIdBeingMigrated)
logger.log('Updating status from ' + statusIdBeingMigrated + ' to ' + newStatus, '')
await client.update(DOMAIN_STATUS, { _id: statusIdBeingMigrated }, { $set: { __superseded: true } })
if (!createdStatuses.has(newStatus)) {
const oldStatus = oldStatuses.find((s) => s._id === statusIdBeingMigrated)
if (oldStatus === undefined) {
logger.log('Old status not found: ', statusIdBeingMigrated)
continue
}
try {
createdStatuses.add(newStatus)
await client.create(DOMAIN_STATUS, {
...oldStatus,
_class: statusClass,
_id: newStatus as any,
ofAttribute: statusAttributeOf,
__migratedFrom: statusIdBeingMigrated
})
} catch (e: any) {
logger.log('Could not create new status: ', e.message)
// Might be already created
}
}
}
logger.log('Statuses created: ', createdStatuses.size)
logger.log('Statuses updated: ', statusIdsBeingMigrated.length)
}
async function migrateRanks (client: MigrationClient): Promise<void> {
const classes = client.hierarchy.getDescendants(task.class.Project)
for (const _class of classes) {
const spaces = await client.find<Project>(DOMAIN_SPACE, { _class })
for (const space of spaces) {
await migrateSpaceRanks(client, DOMAIN_TASK, space)
}
}
}
function areSameArrays (arr1: any[] | undefined, arr2: any[] | undefined): boolean {
if (arr1 === arr2) {
return true
}
if (arr1 === undefined || arr2 === undefined) {
return false
}
return arr1.length === arr2.length && arr1.every((elem, idx) => elem === arr2[idx])
}
export const taskOperation: MigrateOperation = {
async migrate (client: MigrationClient): Promise<void> {
await tryMigrate(client, taskId, [
{
state: 'migrate-tt-model-states',
func: async (client) => {
const prTaskTypes = client.model.findAllSync(task.class.TaskType, {})
const allModelStatuses = toIdMap(client.model.findAllSync(core.class.Status, {}))
for (const tt of prTaskTypes) {
const missing = tt.statuses.filter((it) => !allModelStatuses.has(it))
await client.update(
DOMAIN_TX,
{ objectId: { $in: missing }, objectSpace: 'task:space:Statuses' },
{ $set: { objectSpace: core.space.Model } }
)
}
}
},
{
state: 'removeDeprecatedSpace',
func: async (client: MigrationClient) => {
await migrateSpace(client, task.space.Sequence, core.space.Workspace, [DOMAIN_KANBAN])
}
},
{
state: 'fix-rename-backups',
func: async (client: MigrationClient): Promise<void> => {
await client.update(DOMAIN_TASK, { '%hash%': { $exists: true } }, { $set: { '%hash%': null } })
}
},
{
state: 'migrateRanks',
func: migrateRanks
}
])
},
async upgrade (state: Map<string, Set<string>>, client: () => Promise<MigrationUpgradeClient>): Promise<void> {
await tryUpgrade(state, client, taskId, [
{
state: 'u-task-001',
func: async (client) => {
const tx = new TxOperations(client, core.account.System)
await createOrUpdate(
tx,
tags.class.TagCategory,
core.space.Workspace,
{
icon: tags.icon.Tags,
label: 'Text Label',
targetClass: task.class.Task,
tags: [],
default: true
},
task.category.TaskTag
)
}
}
])
}
}