import { Analytics } from '@hcengineering/analytics' import core, { Hierarchy, type TxApplyIf, type TxCUD, TxProcessor, generateId, type AnyAttribute, type Attribute, type Class, type Client, type Doc, type DocumentQuery, type FindOptions, type FindResult, type Ref, type RefTo, type Tx, type TxResult } from '@hcengineering/core' import { getResource, translate } from '@hcengineering/platform' import { BasePresentationMiddleware, type PresentationMiddleware } from '@hcengineering/presentation' import view, { type AggregationManager } from '@hcengineering/view' /** * @public */ export interface DocSubScriber { attributes: Array> _class: Ref> query: DocumentQuery options?: FindOptions refresh: () => void } /** * @public */ export class AggregationMiddleware extends BasePresentationMiddleware implements PresentationMiddleware { mgrs: Map>, AggregationManager> = new Map>, AggregationManager>() docs: Doc[] | undefined subscribers: Map = new Map() private constructor (client: Client, next?: PresentationMiddleware) { super(client, next) } static create (client: Client, next?: PresentationMiddleware): AggregationMiddleware { return new AggregationMiddleware(client, next) } async notifyTx (tx: Tx): Promise { const promises: Array> = [] for (const [, value] of this.mgrs) { promises.push(value.notifyTx(tx)) } await Promise.all(promises) await this.provideNotifyTx(tx) } async close (): Promise { this.mgrs.forEach((mgr) => { mgr.close() }) await this.provideClose() } async tx (tx: Tx): Promise { return await this.provideTx(tx) } private refreshSubscribers (): void { for (const s of this.subscribers.values()) { // TODO: Do something more smart and track if used component field is changed. s.refresh() } } async subscribe( _class: Ref>, query: DocumentQuery, options: FindOptions | undefined, refresh: () => void ): Promise<{ unsubscribe: () => void query?: DocumentQuery options?: FindOptions }> { const ret = await this.provideSubscribe(_class, query, options, refresh) const h = this.client.getHierarchy() const id = generateId() const s: DocSubScriber = { _class, query, refresh, options, attributes: [] } const statusFields: Array> = [] const allAttrs = h.getAllAttributes(_class) const updatedQuery: DocumentQuery = h.clone(ret.query ?? query) const finalOptions = h.clone(ret.options ?? options ?? {}) await this.updateQueryOptions(allAttrs, h, statusFields, updatedQuery, finalOptions) if (statusFields.length > 0) { this.subscribers.set(id, s) return { unsubscribe: () => { ret.unsubscribe() this.subscribers.delete(id) }, query: updatedQuery, options: finalOptions } } return { unsubscribe: ret.unsubscribe } } private async getAggregationManager (_class: Ref>): Promise { let mgr = this.mgrs.get(_class) if (mgr === undefined) { const h = this.client.getHierarchy() const mixin = h.classHierarchyMixin(_class, view.mixin.Aggregation) if (mixin?.createAggregationManager !== undefined) { const f = await getResource(mixin.createAggregationManager) mgr = f(this.client, () => { this.refreshSubscribers() }) this.mgrs.set(_class, mgr) } } return mgr } async findAll( _class: Ref>, query: DocumentQuery, options?: FindOptions | undefined ): Promise> { const docFields: Array> = [] const h = this.client.getHierarchy() const allAttrs = h.getAllAttributes(_class) const finalOptions = h.clone(options ?? {}) const fquery = h.clone(query ?? {}) await this.updateQueryOptions(allAttrs, h, docFields, fquery, finalOptions) const result = await this.provideFindAll(_class, fquery, finalOptions) // We need to add $ if (docFields.length > 0) { // We need to update $lookup for doc fields and provide $doc group fields. for (const attr of docFields) { for (const r of result) { const resultDoc = Hierarchy.toDoc(r) if (resultDoc.$lookup === undefined) { resultDoc.$lookup = {} } const mgr = await this.getAggregationManager((attr.type as RefTo).to) if (mgr !== undefined) { await mgr.updateLookup(resultDoc, attr) } } } } return result } private async updateQueryOptions( allAttrs: Map, h: Hierarchy, docFields: Array>, query: DocumentQuery, finalOptions: FindOptions ): Promise { for (const attr of allAttrs.values()) { try { if (attr.type._class !== core.class.RefTo) { continue } const mgr = await this.getAggregationManager((attr.type as RefTo).to) if (mgr === undefined) { continue } if (h.isDerived((attr.type as RefTo).to, mgr.getAttrClass())) { let target: Array> = [] let targetNin: Array> = [] docFields.push(attr) const v = (query as any)[attr.name] if (v != null) { // Only add filter if we have filer inside. if (typeof v === 'string') { target.push(v as Ref) } else { if (v.$in !== undefined) { target.push(...v.$in) } else if (v.$nin !== undefined) { targetNin.push(...v.$nin) } else if (v.$ne !== undefined) { targetNin.push(v.$ne) } } // Find all similar name statues for same attribute name. target = await mgr.categorize(target, attr) targetNin = await mgr.categorize(targetNin, attr) if (target.length > 0 || targetNin.length > 0) { ;(query as any)[attr.name] = {} if (target.length > 0) { ;(query as any)[attr.name].$in = target } if (targetNin.length > 0) { ;(query as any)[attr.name].$nin = targetNin } } } if (finalOptions.lookup !== undefined) { // Remove lookups by status field if ((finalOptions.lookup as any)[attr.name] !== undefined) { const { [attr.name]: _, ...newLookup } = finalOptions.lookup as any finalOptions.lookup = newLookup } } // Update sorting if defined. if (mgr.updateSorting !== undefined) { await mgr.updateSorting(finalOptions, attr) } } } catch (err: any) { Analytics.handleError(err) console.error(err) } } } } /** * @public */ export class AnalyticsMiddleware extends BasePresentationMiddleware implements PresentationMiddleware { private constructor (client: Client, next?: PresentationMiddleware) { super(client, next) } async notifyTx (tx: Tx): Promise { await this.provideNotifyTx(tx) } async close (): Promise { await this.provideClose() } static create (client: Client, next?: PresentationMiddleware): AnalyticsMiddleware { return new AnalyticsMiddleware(client, next) } async tx (tx: Tx): Promise { void this.handleTx(tx) return await this.provideTx(tx) } private async handleTx (tx: Tx): Promise { const etx = TxProcessor.extractTx(tx) if (etx._class === core.class.TxApplyIf) { const applyIf = etx as TxApplyIf applyIf.txes.forEach((it) => { void this.handleTx(it) }) } if (this.client.getHierarchy().isDerived(etx._class, core.class.TxCUD)) { const cud = etx as TxCUD const _class = this.client.getHierarchy().getClass(cud.objectClass) const label = await translate(_class.label, {}, 'en') if (cud._class === core.class.TxCreateDoc) { Analytics.handleEvent(`Create ${label}`) } else if (cud._class === core.class.TxUpdateDoc || cud._class === core.class.TxMixin) { Analytics.handleEvent(`Update ${label}`) } else if (cud._class === core.class.TxRemoveDoc) { Analytics.handleEvent(`Delete ${label}`) } } } }