// // Copyright © 2022-2023 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 { ApplyOperations, checkPermission, Class, Data, Doc, DocumentQuery, DocumentUpdate, Hierarchy, Rank, Ref, SortingOrder, Space, Timestamp, toIdMap, TxOperations, type PersonId } from '@hcengineering/core' import { LexoDecimal, LexoNumeralSystem36, LexoRank } from 'lexorank' import LexoRankBucket from 'lexorank/lib/lexoRank/lexoRankBucket' import documents from './plugin' import attachment, { Attachment } from '@hcengineering/attachment' import chunter, { ChatMessage } from '@hcengineering/chunter' import { Employee, getCurrentEmployee, Person } from '@hcengineering/contact' import { makeRank } from '@hcengineering/rank' import tags, { TagReference } from '@hcengineering/tags' import { ChangeControl, ControlledDocument, Document, DocumentMeta, DocumentRequest, DocumentSnapshot, DocumentSpace, DocumentState, Project, ProjectDocument, ProjectMeta } from './types' import { RequestStatus } from '@hcengineering/request' /** * @public */ export const genRanks = (count: number): Generator => (function * () { const sys = new LexoNumeralSystem36() const base = 36 const max = base ** 6 const gap = LexoDecimal.parse(Math.trunc(max / (count + 2)).toString(base), sys) let cur = LexoDecimal.parse('0', sys) for (let i = 0; i < count; i++) { cur = cur.add(gap) yield new LexoRank(LexoRankBucket.BUCKET_0, cur).toString() } })() /** * @public */ export const calcRank = (prev?: { rank: string }, next?: { rank: string }): string => { const a = prev?.rank !== undefined ? LexoRank.parse(prev.rank) : LexoRank.min() const b = next?.rank !== undefined ? LexoRank.parse(next.rank) : LexoRank.max() return a.between(b).toString() } /** * @public */ export async function createChangeControl ( client: TxOperations, ccId: Ref, ccSpec: Data, space: Ref ): Promise { await client.createDoc(documents.class.ChangeControl, space, ccSpec, ccId) } /** * @public */ export function getDocumentId (document: Pick): string { return `${document.prefix}-${document.seqNumber}` } /** @public */ const documentIdRegExp = /^(?\w+)-(?\d+)$/ /** @public */ export function matchDocumentId (str: string): Pick | null { const match = str.match(documentIdRegExp) if (match?.groups?.prefix === undefined || match.groups.seqNumber === undefined) { return null } return { prefix: match.groups.prefix, seqNumber: parseFloat(match.groups.seqNumber) } } /** * @public */ export function isControlledDocument (client: TxOperations, doc: Document): doc is ControlledDocument { return client.getHierarchy().isDerived(doc._class, documents.class.ControlledDocument) } /** * @public */ export type EditorMode = 'viewing' | 'editing' | 'comparing' /** * @public */ export async function deleteProjectDrafts (client: ApplyOperations, source: Ref): Promise { const projectDocs = await client.findAll(documents.class.ProjectDocument, { project: source }) const toDelete = await client.findAll(documents.class.Document, { _id: { $in: projectDocs.map((p) => p.document) }, state: DocumentState.Draft }) for (const doc of toDelete) { await client.update(doc, { state: DocumentState.Deleted }) } } export function isCollaborator (doc: ControlledDocument, person: Ref): boolean { return ( doc.owner === person || doc.coAuthors.includes(person) || doc.approvers.includes(person) || doc.reviewers.includes(person) ) } export function isFolder (doc: ProjectDocument | undefined): boolean { return doc !== undefined && doc.document === documents.ids.Folder } function getDocumentSortSequence (doc: ControlledDocument | undefined): number[] { return doc !== undefined ? [doc.seqNumber, doc.major, doc.minor, doc.createdOn ?? 0] : [0, 0, 0, 0] } export function compareDocumentVersions ( doc1: ControlledDocument | undefined, doc2: ControlledDocument | undefined ): number { const s0 = getDocumentSortSequence(doc1) const s1 = getDocumentSortSequence(doc2) return s0.reduce((r, v, i) => (r !== 0 ? r : s1[i] - v), 0) } function extractPresentableStateFromDocumentBundle (bundle: DocumentBundle, prjmeta: ProjectMeta): DocumentBundle { bundle = { ...bundle } const person = getCurrentEmployee() const documentById = toIdMap(bundle.ControlledDocument) const prjdoc = bundle.ProjectDocument.filter((prjdoc) => { if (prjdoc.attachedTo !== prjmeta._id) return false if (isFolder(prjdoc)) return true const doc = documentById.get(prjdoc.document as Ref) const isPublicState = doc?.state === DocumentState.Effective || doc?.state === DocumentState.Archived return doc !== undefined && (isPublicState || isCollaborator(doc, person)) }).sort((a, b) => { return compareDocumentVersions( documentById.get(a.document as Ref), documentById.get(b.document as Ref) ) })[0] const doc = prjdoc !== undefined ? documentById.get(prjdoc.document as Ref) : undefined bundle.ProjectMeta = [prjmeta] bundle.ProjectDocument = prjdoc !== undefined ? [prjdoc] : [] bundle.ControlledDocument = doc !== undefined ? [doc] : [] return bundle } export interface ProjectDocumentTreeOptions { keepRemoved?: boolean } export class ProjectDocumentTree { parents: Map, Ref> nodesChildren: Map, DocumentBundle[]> nodes: Map, DocumentBundle> links: Map, Ref> constructor (bundle?: DocumentBundle, options?: ProjectDocumentTreeOptions) { bundle = { ...emptyBundle(), ...bundle } const { bundles, links } = compileBundles(bundle) this.links = links this.nodes = new Map() this.nodesChildren = new Map() this.parents = new Map() const keepRemoved = options?.keepRemoved ?? false bundles.sort((a, b) => { const rankA = a.ProjectMeta[0]?.rank ?? '' const rankB = b.ProjectMeta[0]?.rank ?? '' return rankA.localeCompare(rankB) }) for (const bundle of bundles) { const prjmeta = bundle.ProjectMeta[0] if (prjmeta === undefined) continue const presentable = extractPresentableStateFromDocumentBundle(bundle, prjmeta) this.nodes.set(prjmeta.meta, presentable) const parent = prjmeta.parent ?? documents.ids.NoParent this.parents.set(prjmeta.meta, parent) if (!this.nodesChildren.has(parent)) { this.nodesChildren.set(parent, []) } this.nodesChildren.get(parent)?.push(bundle) } const nodesForRemoval = new Set>() if (!keepRemoved) { for (const [id, node] of this.nodes) { const state = node.ControlledDocument[0]?.state const isRemoved = state === DocumentState.Obsolete || state === DocumentState.Deleted if (isRemoved) nodesForRemoval.add(id) } } for (const id of this.nodes.keys()) { if (!nodesForRemoval.has(id)) continue const blocked = this.descendantsOf(id).some((node) => !nodesForRemoval.has(node)) if (blocked) nodesForRemoval.delete(id) } for (const id of nodesForRemoval) { this.nodes.delete(id) this.parents.delete(id) this.nodesChildren.delete(id) } for (const [id, children] of this.nodesChildren) { this.nodesChildren.set( id, children.filter((c) => !nodesForRemoval.has(c.ProjectMeta[0].meta)) ) } } metaOf (ref: Ref | undefined): Ref | undefined { if (ref === undefined) return return this.links.get(ref) } parentChainOf (ref: Ref | undefined): Ref[] { if (ref === undefined) return [] // Found a bug that can cause path field to contain invalid state, // until we fix it with migration and a separate fix it's better to use parent. // // return this.bundleOf(ref)?.ProjectMeta[0]?.path ?? [] const parents: Ref[] = [] while (this.parentOf(ref) !== documents.ids.NoParent) { ref = this.parentOf(ref) parents.push(ref) } return parents } parentOf (ref: Ref | undefined): Ref { if (ref === undefined) { return documents.ids.NoParent } return this.parents.get(ref) ?? documents.ids.NoParent } bundleOf (ref: Ref | undefined): DocumentBundle | undefined { if (ref === undefined) return return this.nodes.get(ref) } childrenOf (ref: Ref | undefined): Ref[] { if (ref === undefined) return [] return this.nodesChildren.get(ref)?.map((p) => p.ProjectMeta[0].meta) ?? [] } descendantsOf (parent: Ref): Ref[] { const result: Ref[] = [] const queue: Ref[] = [parent] while (queue.length > 0) { const next = queue.pop() if (next === undefined) break const children = this.nodesChildren.get(next) ?? [] const childrenRefs = children.map((p) => p.ProjectMeta[0].meta) result.push(...childrenRefs) queue.push(...childrenRefs) } return result } } export async function findProjectDocsHierarchy ( client: TxOperations, space: Ref, project?: Ref> ): Promise { const bundle: DocumentBundle = { ...emptyBundle(), DocumentMeta: await client.findAll(documents.class.DocumentMeta, { space }), ProjectMeta: await client.findAll(documents.class.ProjectMeta, { space, project }) } return new ProjectDocumentTree(bundle, { keepRemoved: true }) } export interface DocumentBundle { DocumentMeta: DocumentMeta[] ProjectMeta: ProjectMeta[] ProjectDocument: ProjectDocument[] ControlledDocument: ControlledDocument[] ChangeControl: ChangeControl[] DocumentRequest: DocumentRequest[] DocumentSnapshot: DocumentSnapshot[] ChatMessage: ChatMessage[] TagReference: TagReference[] Attachment: Attachment[] } export function emptyBundle (): DocumentBundle { return { DocumentMeta: [], ProjectMeta: [], ProjectDocument: [], ControlledDocument: [], ChangeControl: [], DocumentRequest: [], DocumentSnapshot: [], ChatMessage: [], TagReference: [], Attachment: [] } } export function compileBundles (all: DocumentBundle): { bundles: DocumentBundle[] links: Map, Ref> } { const bundles = new Map, DocumentBundle>(all.DocumentMeta.map((m) => [m._id, { ...emptyBundle() }])) const links = new Map, Ref>() const link = (ref: Ref, lookup: Ref): void => { const meta = links.get(lookup) if (meta !== undefined) links.set(ref, meta) } const relink = (ref: Ref, prop: keyof DocumentBundle, obj: DocumentBundle[typeof prop][0]): void => { const meta = links.get(ref) if (meta !== undefined) bundles.get(meta)?.[prop].push(obj as any) } for (const m of all.DocumentMeta) links.set(m._id, m._id) // DocumentMeta -> DocumentMeta for (const m of all.ProjectMeta) links.set(m._id, m.meta) // ProjectMeta -> DocumentMeta for (const m of all.ProjectDocument) { link(m._id, m.attachedTo) // ProjectDocument -> ProjectMeta link(m.document, m.attachedTo) // ControlledDocument -> ProjectMeta } for (const m of all.ControlledDocument) link(m.changeControl, m.attachedTo) // ChangeControl -> ControlledDocument for (const m of all.DocumentRequest) link(m._id, m.attachedTo) // DocumentRequest -> ControlledDocument for (const m of all.DocumentSnapshot) link(m._id, m.attachedTo) // DocumentSnapshot -> ControlledDocument for (const m of all.ChatMessage) link(m._id, m.attachedTo) // ChatMessage -> (ControlledDocument | ChatMessage) for (const m of all.TagReference) link(m._id, m.attachedTo) // TagReference -> ControlledDocument for (const m of all.Attachment) link(m._id, m.attachedTo) // Attachment -> (ControlledDocument | ChatMessage) let key: keyof DocumentBundle for (key in all) { all[key].forEach((value) => { relink(value._id, key, value) }) } return { bundles: Array.from(bundles.values()), links } } export async function findAllDocumentBundles ( client: TxOperations, ids: Ref[] ): Promise { const all: DocumentBundle = { ...emptyBundle() } async function crawl ( _class: Ref>, bkey: keyof DocumentBundle, prop: P, ids: T[P][] ): Promise { const data = await client.findAll(_class, { [prop]: { $in: ids } } as any) all[bkey].push(...(data as any)) return data } await crawl(documents.class.DocumentMeta, 'DocumentMeta', '_id', ids) await crawl( documents.class.ProjectMeta, 'ProjectMeta', 'meta', all.DocumentMeta.map((m) => m._id) ) await crawl( documents.class.ProjectDocument, 'ProjectDocument', 'attachedTo', all.ProjectMeta.map((m) => m._id) ) await crawl( documents.class.ControlledDocument, 'ControlledDocument', 'attachedTo', all.DocumentMeta.map((m) => m._id) ) await crawl( documents.class.ChangeControl, 'ChangeControl', '_id', all.ControlledDocument.map((p) => p.changeControl) ) await crawl( documents.class.DocumentRequest, 'DocumentRequest', 'attachedTo', all.ControlledDocument.map((p) => p._id) ) await crawl( documents.class.DocumentSnapshot, 'DocumentSnapshot', 'attachedTo', all.ControlledDocument.map((p) => p._id) ) await crawl( documents.class.DocumentComment, 'ChatMessage', 'attachedTo', all.ControlledDocument.map((p) => p._id) ) await crawl( chunter.class.ThreadMessage, 'ChatMessage', 'attachedTo', all.ChatMessage.map((p) => p._id) ) await crawl( tags.class.TagReference, 'TagReference', 'attachedTo', all.ControlledDocument.map((p) => p._id) ) await crawl(attachment.class.Attachment, 'Attachment', 'attachedTo', [ ...all.ChatMessage.map((p) => p._id), ...all.ControlledDocument.map((p) => p._id) ]) return compileBundles(all).bundles } export async function findOneDocumentBundle ( client: TxOperations, id: Ref ): Promise { const bundles = await findAllDocumentBundles(client, [id]) return bundles[0] } export interface DocumentTransferRequest { sourceDocumentIds: Ref[] sourceSpaceId: Ref sourceProjectId?: Ref> targetSpaceId: Ref targetParentId?: Ref targetProjectId?: Ref> } interface DocumentTransferContext { request: DocumentTransferRequest bundles: DocumentBundle[] sourceTree: ProjectDocumentTree targetTree: ProjectDocumentTree sourceSpace: DocumentSpace targetSpace: DocumentSpace targetParentBundle?: DocumentBundle } async function _buildDocumentTransferContext ( client: TxOperations, request: DocumentTransferRequest ): Promise { const isSameSpace = request.sourceSpaceId === request.targetSpaceId const sourceTree = await findProjectDocsHierarchy(client, request.sourceSpaceId, request.sourceProjectId) const targetTree = isSameSpace ? sourceTree : await findProjectDocsHierarchy(client, request.targetSpaceId, request.targetProjectId) const docIds = new Set>(request.sourceDocumentIds) for (const id of request.sourceDocumentIds) { sourceTree.descendantsOf(id).forEach((d) => docIds.add(d)) } if (request.targetParentId !== undefined && docIds.has(request.targetParentId)) { return } const bundles = await findAllDocumentBundles(client, Array.from(docIds)) const targetParentBundle = request.targetParentId !== undefined ? await findOneDocumentBundle(client, request.targetParentId) : undefined const sourceSpace = await client.findOne(documents.class.DocumentSpace, { _id: request.sourceSpaceId }) const targetSpace = isSameSpace ? sourceSpace : await client.findOne(documents.class.DocumentSpace, { _id: request.targetSpaceId }) if (sourceSpace === undefined || targetSpace === undefined) return return { request, bundles, sourceTree, targetTree, sourceSpace, targetSpace, targetParentBundle } } export async function listDocumentsAffectedByTransfer ( client: TxOperations, req: DocumentTransferRequest ): Promise { const cx = await _buildDocumentTransferContext(client, req) return cx?.bundles.map((b) => b.DocumentMeta[0]) ?? [] } /** * @public */ export async function canTransferDocuments (client: TxOperations, req: DocumentTransferRequest): Promise { const cx = await _buildDocumentTransferContext(client, req) return cx !== undefined ? await _transferDocuments(client, cx, 'check') : false } /** * @public */ export async function transferDocuments (client: TxOperations, req: DocumentTransferRequest): Promise { const cx = await _buildDocumentTransferContext(client, req) return cx !== undefined ? await _transferDocuments(client, cx) : false } async function _transferDocuments ( client: TxOperations, cx: DocumentTransferContext, mode: 'default' | 'check' = 'default' ): Promise { if (cx.bundles.length < 1) return false const hierarchy = client.getHierarchy() const canArchiveInSourceSpace = await checkPermission( client, documents.permission.ArchiveDocument, cx.request.sourceSpaceId ) const canCreateInTargetSpace = await checkPermission( client, documents.permission.CreateDocument, cx.request.targetSpaceId ) if (!canArchiveInSourceSpace || !canCreateInTargetSpace) return false for (const bundle of cx.bundles) { if (bundle.DocumentMeta.length !== 1) return false if (bundle.ProjectMeta.length !== 1) return false if (bundle.DocumentMeta[0].space !== cx.request.sourceSpaceId) return false const anydoc = bundle.ControlledDocument[0] const isTemplate = anydoc !== undefined && hierarchy.hasMixin(anydoc, documents.mixin.DocumentTemplate) if (isTemplate && hierarchy.isDerived(cx.targetSpace._class, documents.class.ExternalSpace)) return false } const roots = new Set(cx.request.sourceDocumentIds) const updates = new Map>() function update (document: T, update: Partial): void { updates.set(document, { ...updates.get(document), ...update }) } const parentMeta = cx.targetParentBundle?.ProjectMeta[0] const project = cx.request.targetProjectId ?? documents.ids.NoProject if (cx.targetParentBundle !== undefined && parentMeta === undefined) return false let lastRank: Rank | undefined if (parentMeta !== undefined) { lastRank = await getFirstRank(client, cx.targetSpace._id, project, parentMeta.meta) } for (const bundle of cx.bundles) { const projectMeta = bundle.ProjectMeta[0] if (roots.has(projectMeta.meta)) { const path = parentMeta?.path !== undefined ? [parentMeta.meta, ...parentMeta.path] : [] const parent = path[0] ?? documents.ids.NoParent const rank = makeRank(lastRank, undefined) update(projectMeta, { parent, path, rank }) } let key: keyof DocumentBundle for (key in bundle) { for (const doc of bundle[key]) { const space = cx.targetSpace._id if (doc.space !== space) update(doc, { space }) } } for (const m of bundle.ProjectMeta) { if (m.project !== project) update(m, { project }) } for (const m of bundle.ProjectDocument) { if (m.project !== project) update(m, { project }) } } if (mode === 'check') return true const ops = client.apply() for (const u of updates) await ops.update(u[0], u[1]) const commit = await ops.commit() return commit.result } export interface DocumentApprovalState { person?: Ref role: 'author' | 'reviewer' | 'approver' state: 'approved' | 'rejected' | 'cancelled' | 'waiting' timestamp?: Timestamp messages?: ChatMessage[] } export interface DocumentValidationState { requests: DocumentRequest[] snapshot?: DocumentSnapshot document: ControlledDocument approvals: DocumentApprovalState[] modifiedOn?: Timestamp } export function extractValidationWorkflow ( hierarchy: Hierarchy, bundle: DocumentBundle, accountIdToPerson: (ref: PersonId) => Ref | undefined ): Map, DocumentValidationState[]> { const result: ReturnType = new Map() const getApprovalStates = (request: DocumentRequest | undefined): DocumentApprovalState[] => { if (request === undefined) return [] const role = hierarchy.isDerived(request._class, documents.class.DocumentReviewRequest) ? 'reviewer' : 'approver' const rejected: DocumentApprovalState[] = request.rejected !== undefined ? [ { person: request.rejected, role, state: 'rejected', timestamp: request.modifiedOn } ] : [] const approved: DocumentApprovalState[] = request.approved.map((person, idx) => { return { person, role, state: 'approved', timestamp: request.approvedDates?.[idx] ?? request.modifiedOn } }) const ignored: DocumentApprovalState[] = request.requested .filter((person) => person !== request.rejected) .filter((person) => !request.approved.includes(person)) .map((person) => { return { person, role, state: request.rejected !== undefined ? 'cancelled' : 'waiting' } }) const states = [...rejected, ...approved, ...ignored] const messages = bundle.ChatMessage.filter((m) => m.attachedTo === request._id) for (const state of states) { state.messages = messages.filter((m) => accountIdToPerson(m.createdBy ?? m.modifiedBy) === state.person) } return states } for (const document of bundle.ControlledDocument) { const snapshots = bundle.DocumentSnapshot.filter((s) => s.attachedTo === document._id).sort( (a, b) => (a.createdOn ?? 0) - (b.createdOn ?? 0) ) const requests = bundle.DocumentRequest.filter((s) => s.attachedTo === document._id).sort( (a, b) => (a.createdOn ?? 0) - (b.createdOn ?? 0) ) const states: DocumentValidationState[] = [...snapshots, undefined].map((snapshot) => { return { requests: [], snapshot, document, approvals: [], messages: [] } }) for (const request of requests) { if (request.status === RequestStatus.Cancelled) { continue } const state = states.find((s) => (s.snapshot?.createdOn ?? 0) > (request.createdOn ?? 0)) ?? states[states.length - 1] state.requests.push(request) } for (const state of states) { const review = state.requests.findLast((r) => hierarchy.isDerived(r._class, documents.class.DocumentReviewRequest) ) let approval = state.requests.findLast((r) => hierarchy.isDerived(r._class, documents.class.DocumentApprovalRequest) ) if ((approval?.createdOn ?? 0) < (review?.createdOn ?? 0)) approval = undefined const anchor = review ?? approval const author = anchor?.createdBy !== undefined ? accountIdToPerson?.(anchor.createdBy) ?? document.author : document.author state.approvals = [ { person: author, role: 'author', state: anchor !== undefined ? 'approved' : 'waiting', timestamp: anchor !== undefined ? anchor.createdOn ?? document.createdOn : undefined }, ...getApprovalStates(review), ...getApprovalStates(approval) ] if (state.requests.length > 0) { state.modifiedOn = Math.max(...state.requests.map((r) => r.modifiedOn ?? 0)) } } states.reverse() result.set(document._id, states) } return result } /** * @public */ export async function copyProjectDocuments ( client: ApplyOperations, source: Ref, target: Ref ): Promise { const projectMeta = await client.findAll(documents.class.ProjectMeta, { project: source }) const projectDocs = await client.findAll(documents.class.ProjectDocument, { project: source }) const projectDocsByMeta = new Map, ProjectDocument[]>() for (const doc of projectDocs) { const docs = projectDocsByMeta.get(doc.attachedTo) ?? [] docs.push(doc) projectDocsByMeta.set(doc.attachedTo, docs) } for (const meta of projectMeta) { // copy meta const projectMetaId = await client.createDoc(documents.class.ProjectMeta, meta.space, { project: target, meta: meta.meta, path: meta.path, parent: meta.parent, documents: meta.documents, rank: meta.rank }) // copy project docs attached to meta const projectDocs = projectDocsByMeta.get(meta._id) ?? [] for (const doc of projectDocs) { await client.addCollection( documents.class.ProjectDocument, meta.space, projectMetaId, documents.class.ProjectMeta, 'documents', { project: target, initial: doc.initial, document: doc.document } ) } } } /** * @public */ export async function getFirstRank ( client: TxOperations, space: Ref, project: Ref, parent: Ref, sort: SortingOrder = SortingOrder.Descending, extra: DocumentQuery = {} ): Promise { const doc = await client.findOne( documents.class.ProjectMeta, { space, project, parent, ...extra }, { sort: { rank: sort }, projection: { rank: 1 } } ) return doc?.rank } /** * @public */ export function getEffectiveDocUpdates (): DocumentUpdate[] { return [ { state: DocumentState.Effective, effectiveDate: Date.now() }, { $unset: { controlledState: true } } ] } /** * @public */ export function getDocumentName (doc: Document): string { return `${doc.code} ${doc.title}` } export const periodicReviewIntervals: readonly number[] = [6, 12, 18, 24, 30, 36] /** * @public */ export const DEFAULT_PERIODIC_REVIEW_INTERVAL: Readonly = periodicReviewIntervals[1] /** * @public */ export const TEMPLATE_PREFIX = 'TMPL'