platform/plugins/controlled-documents/src/utils.ts
Victor Ilyushchenko 9e916670e1
Some checks are pending
CI / build (push) Waiting to run
CI / svelte-check (push) Blocked by required conditions
CI / formatting (push) Blocked by required conditions
CI / test (push) Blocked by required conditions
CI / uitest (push) Waiting to run
CI / uitest-pg (push) Waiting to run
CI / uitest-qms (push) Waiting to run
CI / uitest-workspaces (push) Waiting to run
CI / docker-build (push) Blocked by required conditions
CI / dist-build (push) Blocked by required conditions
EQMS-1411: Fixed the approval-to-effective status transition in QMS documents (#8598)
Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>
2025-04-17 09:15:07 +07:00

903 lines
27 KiB
TypeScript

//
// 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<string, void, unknown> =>
(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<ChangeControl>,
ccSpec: Data<ChangeControl>,
space: Ref<DocumentSpace>
): Promise<void> {
await client.createDoc(documents.class.ChangeControl, space, ccSpec, ccId)
}
/**
* @public
*/
export function getDocumentId (document: Pick<Document, 'prefix' | 'seqNumber'>): string {
return `${document.prefix}-${document.seqNumber}`
}
/** @public */
const documentIdRegExp = /^(?<prefix>\w+)-(?<seqNumber>\d+)$/
/** @public */
export function matchDocumentId (str: string): Pick<Document, 'prefix' | 'seqNumber'> | 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<Project>): Promise<void> {
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<Employee>): 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<ControlledDocument>)
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<ControlledDocument>),
documentById.get(b.document as Ref<ControlledDocument>)
)
})[0]
const doc = prjdoc !== undefined ? documentById.get(prjdoc.document as Ref<ControlledDocument>) : 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<DocumentMeta>, Ref<DocumentMeta>>
nodesChildren: Map<Ref<DocumentMeta>, DocumentBundle[]>
nodes: Map<Ref<DocumentMeta>, DocumentBundle>
links: Map<Ref<Doc>, Ref<DocumentMeta>>
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<Ref<DocumentMeta>>()
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<Doc> | undefined): Ref<DocumentMeta> | undefined {
if (ref === undefined) return
return this.links.get(ref)
}
parentChainOf (ref: Ref<DocumentMeta> | undefined): Ref<DocumentMeta>[] {
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<DocumentMeta>[] = []
while (this.parentOf(ref) !== documents.ids.NoParent) {
ref = this.parentOf(ref)
parents.push(ref)
}
return parents
}
parentOf (ref: Ref<DocumentMeta> | undefined): Ref<DocumentMeta> {
if (ref === undefined) {
return documents.ids.NoParent
}
return this.parents.get(ref) ?? documents.ids.NoParent
}
bundleOf (ref: Ref<DocumentMeta> | undefined): DocumentBundle | undefined {
if (ref === undefined) return
return this.nodes.get(ref)
}
childrenOf (ref: Ref<DocumentMeta> | undefined): Ref<DocumentMeta>[] {
if (ref === undefined) return []
return this.nodesChildren.get(ref)?.map((p) => p.ProjectMeta[0].meta) ?? []
}
descendantsOf (parent: Ref<DocumentMeta>): Ref<DocumentMeta>[] {
const result: Ref<DocumentMeta>[] = []
const queue: Ref<DocumentMeta>[] = [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<DocumentSpace>,
project?: Ref<Project<DocumentSpace>>
): Promise<ProjectDocumentTree> {
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<Doc>, Ref<DocumentMeta>>
} {
const bundles = new Map<Ref<DocumentMeta>, DocumentBundle>(all.DocumentMeta.map((m) => [m._id, { ...emptyBundle() }]))
const links = new Map<Ref<Doc>, Ref<DocumentMeta>>()
const link = (ref: Ref<Doc>, lookup: Ref<Doc>): void => {
const meta = links.get(lookup)
if (meta !== undefined) links.set(ref, meta)
}
const relink = (ref: Ref<Doc>, 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<DocumentMeta>[]
): Promise<DocumentBundle[]> {
const all: DocumentBundle = { ...emptyBundle() }
async function crawl<T extends Doc, P extends keyof T> (
_class: Ref<Class<T>>,
bkey: keyof DocumentBundle,
prop: P,
ids: T[P][]
): Promise<T[]> {
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<DocumentMeta>
): Promise<DocumentBundle | undefined> {
const bundles = await findAllDocumentBundles(client, [id])
return bundles[0]
}
export interface DocumentTransferRequest {
sourceDocumentIds: Ref<DocumentMeta>[]
sourceSpaceId: Ref<DocumentSpace>
sourceProjectId?: Ref<Project<DocumentSpace>>
targetSpaceId: Ref<DocumentSpace>
targetParentId?: Ref<DocumentMeta>
targetProjectId?: Ref<Project<DocumentSpace>>
}
interface DocumentTransferContext {
request: DocumentTransferRequest
bundles: DocumentBundle[]
sourceTree: ProjectDocumentTree
targetTree: ProjectDocumentTree
sourceSpace: DocumentSpace
targetSpace: DocumentSpace
targetParentBundle?: DocumentBundle
}
async function _buildDocumentTransferContext (
client: TxOperations,
request: DocumentTransferRequest
): Promise<DocumentTransferContext | undefined> {
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<Ref<DocumentMeta>>(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<DocumentMeta[]> {
const cx = await _buildDocumentTransferContext(client, req)
return cx?.bundles.map((b) => b.DocumentMeta[0]) ?? []
}
/**
* @public
*/
export async function canTransferDocuments (client: TxOperations, req: DocumentTransferRequest): Promise<boolean> {
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<boolean> {
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<boolean> {
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<Doc, Partial<Doc>>()
function update<T extends Doc> (document: T, update: Partial<T>): 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<Person>
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<Person> | undefined
): Map<Ref<ControlledDocument>, DocumentValidationState[]> {
const result: ReturnType<typeof extractValidationWorkflow> = 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<Project>,
target: Ref<Project>
): Promise<void> {
const projectMeta = await client.findAll(documents.class.ProjectMeta, { project: source })
const projectDocs = await client.findAll(documents.class.ProjectDocument, { project: source })
const projectDocsByMeta = new Map<Ref<ProjectMeta>, 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<Space>,
project: Ref<Project>,
parent: Ref<DocumentMeta>,
sort: SortingOrder = SortingOrder.Descending,
extra: DocumentQuery<ProjectMeta> = {}
): Promise<Rank | undefined> {
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<ControlledDocument>[] {
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<number> = periodicReviewIntervals[1]
/**
* @public
*/
export const TEMPLATE_PREFIX = 'TMPL'