// // Copyright © 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 activity, { ActivityMessage, ActivityReference, UserMentionInfo } from '@hcengineering/activity' import { loadCollaborativeDoc, yDocToBuffer } from '@hcengineering/collaboration' import contact, { Employee, Person, PersonAccount } from '@hcengineering/contact' import core, { Account, AttachedDoc, Class, CollaborativeDoc, Data, Doc, generateId, Hierarchy, Ref, Space, Tx, TxCollectionCUD, TxCreateDoc, TxCUD, TxFactory, TxMixin, TxProcessor, TxRemoveDoc, TxUpdateDoc, Type, type MeasureContext } from '@hcengineering/core' import notification, { CommonInboxNotification, MentionInboxNotification } from '@hcengineering/notification' import { StorageAdapter, TriggerControl } from '@hcengineering/server-core' import { applyNotificationProviders, getCommonNotificationTxes, getNotificationContent, getNotificationProviderControl, getPushCollaboratorTx, isShouldNotifyTx, NotifyResult, shouldNotifyCommon, toReceiverInfo, type NotificationProviderControl } from '@hcengineering/server-notification-resources' import { areEqualJson, extractReferences, markupToPmNode, pmNodeToMarkup, yDocContentToNodes } from '@hcengineering/text' export function isDocMentioned (doc: Ref, content: string | Buffer): boolean { const references = [] if (content instanceof Buffer) { const nodes = yDocContentToNodes(content) for (const node of nodes) { references.push(...extractReferences(node)) } } else { const doc = markupToPmNode(content) references.push(...extractReferences(doc)) } for (const ref of references) { if (ref.objectId === doc) { return true } } return false } export async function getPersonNotificationTxes ( ctx: MeasureContext, reference: Data, control: TriggerControl, senderId: Ref, space: Ref, originTx: TxCUD, notificationControl: NotificationProviderControl ): Promise { const receiverPersonId = reference.attachedTo as Ref const receiver = control.modelDb.getAccountByPersonId(receiverPersonId) as PersonAccount[] if (receiver.length === 0) { return [] } if (receiver.some((it) => it._id === senderId)) { return [] } const res: Tx[] = [] const isAvailable = await checkSpace(receiver, space, control, res) if (!isAvailable) { return [] } const doc = (await control.findAll(ctx, reference.srcDocClass, { _id: reference.srcDocId }))[0] const receiverPerson = ( await control.findAll( ctx, contact.mixin.Employee, { _id: receiverPersonId as Ref, active: true }, { limit: 1 } ) )[0] if (receiverPerson === undefined) return res const receiverSpace = ( await control.findAll(ctx, contact.class.PersonSpace, { person: receiverPersonId }, { limit: 1 }) )[0] if (receiverSpace === undefined) return res // TODO: Do we need for all or just one? const collaboratorsTx = await getCollaboratorsTxes(reference, control, receiver[0], doc) res.push(...collaboratorsTx) if (doc === undefined) { return res } const info = ( await control.findAll(ctx, activity.class.UserMentionInfo, { user: receiverPersonId, attachedTo: reference.attachedDocId }) )[0] if (info === undefined) { res.push( control.txFactory.createTxCreateDoc(activity.class.UserMentionInfo, space, { attachedTo: reference.attachedDocId ?? reference.srcDocId, attachedToClass: reference.attachedDocClass ?? reference.srcDocClass, user: receiverPersonId, content: reference.message, collection: 'mentions' }) ) } else { res.push( control.txFactory.createTxUpdateDoc(info._class, info.space, info._id, { content: reference.message }) ) } // TODO: Select a proper reciever const data: Omit, 'docNotifyContext'> = { header: activity.string.MentionedYouIn, messageHtml: reference.message, mentionedIn: reference.attachedDocId ?? reference.srcDocId, mentionedInClass: reference.attachedDocClass ?? reference.srcDocClass, user: receiver[0]._id, isViewed: false, archived: false } const sender = ( await control.modelDb.findAll(contact.class.PersonAccount, { _id: senderId as Ref }, { limit: 1 }) )[0] const senderPerson = sender !== undefined ? (await control.findAll(ctx, contact.class.Person, { _id: sender.person }, { limit: 1 }))[0] : undefined const receiverInfo = toReceiverInfo(control.hierarchy, { _id: receiver[0]._id, account: receiver[0], person: receiverPerson, space: receiverSpace._id }) if (receiverInfo === undefined) return res const senderInfo = { _id: senderId, account: sender, person: senderPerson } const notifyResult = await shouldNotifyCommon( control, receiver.map((it) => it._id), notification.ids.MentionCommonNotificationType, notificationControl ) const messageNotifyResult = await getMessageNotifyResult( reference, receiver, control, originTx, doc, notificationControl ) for (const [provider] of messageNotifyResult.entries()) { if (notifyResult.has(provider)) { notifyResult.delete(provider) } } if (notifyResult.has(notification.providers.InboxNotificationProvider)) { const txes = await getCommonNotificationTxes( ctx, control, doc, data, receiverInfo, senderInfo, reference.srcDocId, reference.srcDocClass, doc.space, originTx.modifiedOn, notifyResult, notification.class.MentionInboxNotification, originTx ) res.push(...txes) } else { const context = ( await control.findAll( ctx, notification.class.DocNotifyContext, { objectId: reference.srcDocId, user: { $in: receiver.map((it) => it._id) } }, { projection: { _id: 1 } } ) )[0] if (context !== undefined) { const content = await getNotificationContent(originTx, receiver, senderInfo, doc, control) const notificationData: CommonInboxNotification = { ...data, ...content, docNotifyContext: context._id, _id: generateId(), _class: notification.class.CommonInboxNotification, space: receiverSpace._id, modifiedOn: originTx.modifiedOn, modifiedBy: sender._id } const subscriptions = await control.findAll(control.ctx, notification.class.PushSubscription, { user: receiverInfo._id }) await applyNotificationProviders( notificationData, notifyResult, reference.srcDocId, reference.srcDocClass, control, res, doc, receiverInfo, senderInfo, subscriptions ) } } return res } async function checkSpace ( users: PersonAccount[], spaceId: Ref, control: TriggerControl, res: Tx[] ): Promise { const space = (await control.findAll(control.ctx, core.class.Space, { _id: spaceId }, { limit: 1 }))[0] const toAdd = users.filter((user) => !space.members.includes(user._id)) const isMember = toAdd.length === 0 if (space.private) { return isMember } if (!isMember) { for (const user of toAdd) { res.push( control.txFactory.createTxUpdateDoc(space._class, space.space, space._id, { $push: { members: user._id } }) ) } } return true } async function getCollaboratorsTxes ( reference: Data, control: TriggerControl, receiver: Account, object?: Doc ): Promise[]> { const { hierarchy } = control const res: TxMixin[] = [] if (object !== undefined) { // Add user to collaborators of object where user is mentioned const objectTx = getPushCollaboratorTx(control, receiver._id, object) if (objectTx !== undefined) { res.push(objectTx) } } if (reference.attachedDocClass === undefined || reference.attachedDocId === undefined) { return res } if (!hierarchy.isDerived(reference.attachedDocClass, activity.class.ActivityMessage)) { return res } const message = ( await control.findAll( control.ctx, reference.attachedDocClass, { _id: reference.attachedDocId as Ref }, { limit: 1 } ) )[0] if (message === undefined) { return res } // Add user to collaborators of message where user is mentioned const messageTx = getPushCollaboratorTx(control, receiver._id, message) if (messageTx !== undefined) { res.push(messageTx) } return res } async function getMessageNotifyResult ( reference: Data, account: PersonAccount[], control: TriggerControl, originTx: TxCUD, doc: Doc, notificationControl: NotificationProviderControl ): Promise { const { hierarchy } = control const tx = TxProcessor.extractTx(originTx) as TxCUD if ( reference.attachedDocClass === undefined || reference.attachedDocId === undefined || tx._class !== core.class.TxCreateDoc ) { return new Map() } const mixin = control.hierarchy.as(doc, notification.mixin.Collaborators) if (mixin === undefined || !account.some((account) => mixin.collaborators.includes(account._id))) { return new Map() } if (!hierarchy.isDerived(reference.attachedDocClass, activity.class.ActivityMessage)) { return new Map() } return await isShouldNotifyTx(control, tx, originTx, doc, account, false, false, notificationControl, undefined) } function isMarkupType (type: Ref>>): boolean { return type === core.class.TypeMarkup } function isCollaborativeType (type: Ref>>): boolean { return type === core.class.TypeCollaborativeDoc } async function getCreateReferencesTxes ( ctx: MeasureContext, control: TriggerControl, storage: StorageAdapter, txFactory: TxFactory, createdDoc: Doc, srcDocId: Ref, srcDocClass: Ref>, srcDocSpace: Ref, originTx: TxCUD ): Promise { const attachedDocId = createdDoc._id const attachedDocClass = createdDoc._class const refs: Data[] = [] const attributes = control.hierarchy.getAllAttributes(createdDoc._class) for (const attr of attributes.values()) { if (isMarkupType(attr.type._class)) { const content = (createdDoc as any)[attr.name]?.toString() ?? '' const attrReferences = getReferencesData(srcDocId, srcDocClass, attachedDocId, attachedDocClass, content) refs.push(...attrReferences) } else if (attr.type._class === core.class.TypeCollaborativeDoc) { const collaborativeDoc = (createdDoc as any)[attr.name] as CollaborativeDoc try { const ydoc = await loadCollaborativeDoc(storage, control.workspace, collaborativeDoc, control.ctx) if (ydoc !== undefined) { const attrReferences = getReferencesData( srcDocId, srcDocClass, attachedDocId, attachedDocClass, yDocToBuffer(ydoc) ) refs.push(...attrReferences) } } catch { // do nothing, the collaborative doc does not sem to exist yet } } } const refSpace: Ref = control.hierarchy.isDerived(srcDocClass, core.class.Space) ? (srcDocId as Ref) : srcDocSpace return await getReferencesTxes(ctx, control, txFactory, refs, refSpace, [], [], originTx) } async function getUpdateReferencesTxes ( ctx: MeasureContext, control: TriggerControl, storage: StorageAdapter, txFactory: TxFactory, updatedDoc: Doc, srcDocId: Ref, srcDocClass: Ref>, srcDocSpace: Ref, originTx: TxCUD ): Promise { const attachedDocId = updatedDoc._id const attachedDocClass = updatedDoc._class // collect attribute references let hasReferenceAttrs = false const references: Data[] = [] const attributes = control.hierarchy.getAllAttributes(updatedDoc._class) for (const attr of attributes.values()) { if (isMarkupType(attr.type._class)) { hasReferenceAttrs = true const content = (updatedDoc as any)[attr.name]?.toString() ?? '' const attrReferences = getReferencesData(srcDocId, srcDocClass, attachedDocId, attachedDocClass, content) references.push(...attrReferences) } else if (attr.type._class === core.class.TypeCollaborativeDoc) { hasReferenceAttrs = true try { const collaborativeDoc = (updatedDoc as any)[attr.name] as CollaborativeDoc const ydoc = await loadCollaborativeDoc(storage, control.workspace, collaborativeDoc, control.ctx) if (ydoc !== undefined) { const attrReferences = getReferencesData( srcDocId, srcDocClass, attachedDocId, attachedDocClass, yDocToBuffer(ydoc) ) references.push(...attrReferences) } } catch { // do nothing, the collaborative doc does not sem to exist yet } } } // There is a chance that references are managed manually // do not update references if there are no reference sources in the doc if (hasReferenceAttrs) { const current = await control.findAll(ctx, activity.class.ActivityReference, { srcDocId, srcDocClass, attachedDocId, collection: 'references' }) const userMentions = await control.findAll(ctx, activity.class.UserMentionInfo, { attachedTo: attachedDocId }) const refSpace: Ref = control.hierarchy.isDerived(srcDocClass, core.class.Space) ? (srcDocId as Ref) : srcDocSpace return await getReferencesTxes(ctx, control, txFactory, references, refSpace, current, userMentions, originTx) } return [] } export function getReferencesData ( srcDocId: Ref, srcDocClass: Ref>, attachedDocId: Ref | undefined, attachedDocClass: Ref> | undefined, content: string | Buffer ): Array> { const result: Array> = [] const references = [] if (content instanceof Buffer) { const nodes = yDocContentToNodes(content) for (const node of nodes) { references.push(...extractReferences(node)) } } else { const doc = markupToPmNode(content) references.push(...extractReferences(doc)) } for (const ref of references) { if (ref.objectId !== attachedDocId && ref.objectId !== srcDocId) { result.push({ attachedTo: ref.objectId, attachedToClass: ref.objectClass, collection: 'references', srcDocId, srcDocClass, message: ref.parentNode !== null ? pmNodeToMarkup(ref.parentNode) : '', attachedDocId, attachedDocClass }) } } return result } async function createReferenceTxes ( ctx: MeasureContext, control: TriggerControl, txFactory: TxFactory, ref: Data, space: Ref, originTx: TxCUD, notificationControl: NotificationProviderControl ): Promise { if (control.hierarchy.isDerived(ref.attachedToClass, contact.class.Person)) { return await getPersonNotificationTxes(ctx, ref, control, txFactory.account, space, originTx, notificationControl) } const refTx = control.txFactory.createTxCreateDoc(activity.class.ActivityReference, space, ref) const tx = control.txFactory.createTxCollectionCUD(ref.attachedToClass, ref.attachedTo, space, ref.collection, refTx) return [tx] } async function getReferencesTxes ( ctx: MeasureContext, control: TriggerControl, txFactory: TxFactory, references: Data[], space: Ref, current: ActivityReference[], mentions: UserMentionInfo[], originTx: TxCUD ): Promise { const txes: Tx[] = [] for (const c of current) { // Find existing and check if we need to update message const pos = references.findIndex( (b) => b.srcDocId === c.srcDocId && b.srcDocClass === c.srcDocClass && b.attachedTo === c.attachedTo ) if (pos !== -1) { // Update existing references when message changed const data = references[pos] if (c.message !== data.message) { const innerTx = txFactory.createTxUpdateDoc(c._class, c.space, c._id, { message: data.message }) txes.push(txFactory.createTxCollectionCUD(c.attachedToClass, c.attachedTo, c.space, c.collection, innerTx)) } references.splice(pos, 1) } else { // Remove not found references const innerTx = txFactory.createTxRemoveDoc(c._class, c.space, c._id) txes.push(txFactory.createTxCollectionCUD(c.attachedToClass, c.attachedTo, c.space, c.collection, innerTx)) } } const notificationControl = await getNotificationProviderControl(ctx, control) for (const mention of mentions) { const refIndex = references.findIndex( (r) => mention.user === r.attachedTo && mention.attachedTo === r.attachedDocId ) const ref = references[refIndex] if (refIndex !== -1) { const alreadyProcessed = areEqualJson(JSON.parse(mention.content), JSON.parse(ref.message)) if (alreadyProcessed) { references.splice(refIndex, 1) } } else { txes.push(txFactory.createTxRemoveDoc(mention._class, mention.space, mention._id)) } } // Add missing references for (const ref of references) { txes.push(...(await createReferenceTxes(ctx, control, txFactory, ref, space, originTx, notificationControl))) } return txes } async function getRemoveActivityReferenceTxes ( control: TriggerControl, txFactory: TxFactory, removedDocId: Ref ): Promise { const txes: Tx[] = [] const refs = await control.findAll(control.ctx, activity.class.ActivityReference, { attachedDocId: removedDocId, collection: 'references' }) const mentions = await control.findAll(control.ctx, activity.class.UserMentionInfo, { attachedTo: removedDocId }) for (const ref of refs) { const removeTx = txFactory.createTxRemoveDoc(ref._class, ref.space, ref._id) txes.push(txFactory.createTxCollectionCUD(ref.attachedToClass, ref.attachedTo, ref.space, ref.collection, removeTx)) } for (const mention of mentions) { const removeTx = txFactory.createTxRemoveDoc(mention._class, mention.space, mention._id) txes.push( txFactory.createTxCollectionCUD( mention.attachedToClass, mention.attachedTo, mention.space, mention.collection, removeTx ) ) } return txes } function guessReferenceTx (hierarchy: Hierarchy, tx: TxCUD): TxCUD { // Try to guess reference target Tx for TxCollectionCUD txes based on collaborators availability if (hierarchy.isDerived(tx._class, core.class.TxCollectionCUD)) { const cltx = tx as TxCollectionCUD tx = TxProcessor.extractTx(cltx) as TxCUD if (hierarchy.isDerived(tx.objectClass, activity.class.ActivityMessage)) { return cltx } const mixin = hierarchy.classHierarchyMixin(tx.objectClass, notification.mixin.ClassCollaborators) return mixin !== undefined ? tx : cltx } return tx } async function ActivityReferenceCreate (tx: TxCUD, etx: TxCUD, control: TriggerControl): Promise { const ctx = etx as TxCreateDoc if (ctx._class !== core.class.TxCreateDoc) return [] if (control.hierarchy.isDerived(ctx.objectClass, notification.class.InboxNotification)) return [] if (control.hierarchy.isDerived(ctx.objectClass, activity.class.ActivityReference)) return [] const txFactory = new TxFactory(control.txFactory.account) const doc = TxProcessor.createDoc2Doc(ctx) const targetTx = guessReferenceTx(control.hierarchy, tx) const txes: Tx[] = await getCreateReferencesTxes( control.ctx, control, control.storageAdapter, txFactory, doc, targetTx.objectId, targetTx.objectClass, targetTx.objectSpace, tx ) if (txes.length !== 0) { await control.apply(control.ctx, txes) } return [] } async function ActivityReferenceUpdate (tx: TxCUD, etx: TxCUD, control: TriggerControl): Promise { const ctx = etx as TxUpdateDoc const attributes = control.hierarchy.getAllAttributes(ctx.objectClass) let hasUpdates = false for (const attr of attributes.values()) { if (isMarkupType(attr.type._class) || isCollaborativeType(attr.type._class)) { if (TxProcessor.txHasUpdate(ctx, attr.name)) { hasUpdates = true break } } } if (!hasUpdates) { return [] } const rawDoc = (await control.findAll(control.ctx, ctx.objectClass, { _id: ctx.objectId }))[0] if (rawDoc === undefined) { return [] } const txFactory = new TxFactory(control.txFactory.account) const doc = TxProcessor.updateDoc2Doc(rawDoc, ctx) const targetTx = guessReferenceTx(control.hierarchy, tx) const txes: Tx[] = await getUpdateReferencesTxes( control.ctx, control, control.storageAdapter, txFactory, doc, targetTx.objectId, targetTx.objectClass, targetTx.objectSpace, tx ) if (txes.length !== 0) { await control.apply(control.ctx, txes) } return [] } async function ActivityReferenceRemove (tx: Tx, etx: TxCUD, control: TriggerControl): Promise { const ctx = etx as TxRemoveDoc const attributes = control.hierarchy.getAllAttributes(ctx.objectClass) let hasMarkdown = false for (const attr of attributes.values()) { if (isMarkupType(attr.type._class) || isCollaborativeType(attr.type._class)) { hasMarkdown = true break } } if (hasMarkdown) { const txFactory = new TxFactory(control.txFactory.account) const txes: Tx[] = await getRemoveActivityReferenceTxes(control, txFactory, ctx.objectId) if (txes.length !== 0) { await control.apply(control.ctx, txes) } } return [] } /** * @public */ export async function ReferenceTrigger (tx: TxCUD, control: TriggerControl): Promise { const result: Tx[] = [] const etx = TxProcessor.extractTx(tx) as TxCUD if (control.hierarchy.isDerived(etx.objectClass, activity.class.ActivityReference)) return [] if (control.hierarchy.isDerived(etx.objectClass, notification.class.InboxNotification)) return [] if (etx._class === core.class.TxCreateDoc) { result.push(...(await ActivityReferenceCreate(tx, etx, control))) } if (etx._class === core.class.TxUpdateDoc) { result.push(...(await ActivityReferenceUpdate(tx, etx, control))) } if (etx._class === core.class.TxRemoveDoc) { result.push(...(await ActivityReferenceRemove(tx, etx, control))) } return result }