// // Copyright © 2020, 2021 Anticrm Platform Contributors. // Copyright © 2021, 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, { ActivityMessage, DocUpdateMessage } from '@hcengineering/activity' import { Analytics } from '@hcengineering/analytics' import chunter, { ChatMessage } from '@hcengineering/chunter' import contact, { Employee, type Person } from '@hcengineering/contact' import core, { AccountUuid, AnyAttribute, ArrOf, AttachedDoc, Class, ClassCollaborators, Collaborator, Collection, combineAttributes, Data, Doc, DocumentUpdate, MeasureContext, MixinUpdate, notEmpty, PersonId, readOnlyGuestAccountUuid, Ref, RefTo, SortingOrder, Space, Timestamp, Tx, TxCreateDoc, TxCUD, TxMixin, TxProcessor, TxRemoveDoc, TxUpdateDoc, getClassCollaborators } from '@hcengineering/core' import notification, { ActivityInboxNotification, CommonInboxNotification, DocNotifyContext, InboxNotification, MentionInboxNotification, NotificationType } from '@hcengineering/notification' import { getResource, translate } from '@hcengineering/platform' import { getAccountBySocialId, getEmployeesBySocialIds } from '@hcengineering/server-contact' import { type TriggerControl } from '@hcengineering/server-core' import { NOTIFICATION_BODY_SIZE, ReceiverInfo, SenderInfo } from '@hcengineering/server-notification' import { markupToText, stripTags } from '@hcengineering/text-core' import { PushNotificationsHandler } from './push' import { AvailableProvidersCache, AvailableProvidersCacheKey, Content, ContextsCache, ContextsCacheKey, NotifyParams, NotifyResult } from './types' import { getHTMLPresenter, getNotificationContent, getNotificationLink, getNotificationProviderControl, getObjectSpace, getReceiversInfo, getSenderInfo, getTextPresenter, isMixinTx, isShouldNotifyTx, isUserEmployeeInFieldValueTypeMatch, mentionTypeMatch, messageToMarkup, type NotificationProviderControl, replaceAll, updateNotifyContextsSpace } from './utils' export async function getCommonNotificationTxes ( ctx: MeasureContext, control: TriggerControl, doc: Doc, data: Partial>, receiver: ReceiverInfo, sender: SenderInfo, attachedTo: Ref, attachedToClass: Ref>, space: Ref, modifiedOn: Timestamp, notifyResult: NotifyResult, _class = notification.class.CommonInboxNotification, tx?: TxCUD ): Promise { if (notifyResult.size === 0 || !notifyResult.has(notification.providers.InboxNotificationProvider)) { return [] } const res: Tx[] = [] const notifyContexts = await control.findAll(ctx, notification.class.DocNotifyContext, { objectId: attachedTo }) await pushInboxNotifications( ctx, control, res, receiver, sender, attachedTo, attachedToClass, space, notifyContexts, data, _class, modifiedOn, [], true, tx ) return res } async function getTextPart (doc: Doc, control: TriggerControl): Promise { const TextPresenter = getTextPresenter(doc._class, control.hierarchy) if (TextPresenter === undefined) return return await ( await getResource(TextPresenter.presenter) )(doc, control) } async function getHtmlPart (doc: Doc, control: TriggerControl): Promise { const HTMLPresenter = getHTMLPresenter(doc._class, control.hierarchy) return HTMLPresenter != null ? await (await getResource(HTMLPresenter.presenter))(doc, control) : undefined } function fillTemplate ( template: string, sender: string, doc: string, data: string, params: Record = {} ): string { let res = replaceAll(template, '{sender}', sender) res = replaceAll(res, '{doc}', doc) res = replaceAll(res, '{data}', data) for (const key in params) { res = replaceAll(res, `{${key}}`, params[key]) } return res } /** * @public */ export async function getContentByTemplate ( doc: Doc | undefined, sender: string, type: Ref, control: TriggerControl, data: string, notificationData?: InboxNotification, message?: ActivityMessage ): Promise { if (doc === undefined) return const notificationType = control.modelDb.getObject(type) if (notificationType.templates === undefined) return const params: Record = notificationData !== undefined ? await getTranslatedNotificationContent(notificationData, notificationData._class, control) : {} let textPart = await getTextPart(doc, control) if (textPart === undefined) { if ( notificationData !== undefined && control.hierarchy.isDerived(notificationData._class, notification.class.CommonInboxNotification) ) { textPart = params.title ?? params.body ?? '' } if (textPart === undefined || textPart === '') return } if ( notificationData !== undefined && control.hierarchy.isDerived(notificationData._class, notification.class.MentionInboxNotification) ) { const messageContent = (notificationData as MentionInboxNotification).messageHtml const text = messageContent !== undefined ? markupToText(messageContent) : undefined params.body = text ?? params.body params.message = text ?? params.message } if (message !== undefined) { const markup = await messageToMarkup(control, message) params.message = markup !== undefined ? markupToText(markup) : (params.message ?? '') } else if (params.message === undefined) { params.message = params.body ?? '' } const link = await getNotificationLink(control, doc, message?._id) const app = control.branding?.title ?? 'Huly' const linkText = await translate(notification.string.ViewIn, { app }) params.link = `${linkText}` const text = fillTemplate(notificationType.templates.textTemplate, sender, textPart, data, params) const htmlPart = await getHtmlPart(doc, control) const html = fillTemplate(notificationType.templates.htmlTemplate, sender, htmlPart ?? textPart, data, params) const subject = fillTemplate(notificationType.templates.subjectTemplate, sender, textPart, data, params) if (subject === '') return return { text, html, subject } } async function getValueCollaborators (value: any, attr: AnyAttribute, control: TriggerControl): Promise { const hierarchy = control.hierarchy if (attr.type._class === core.class.RefTo) { const to = (attr.type as RefTo).to if (hierarchy.isDerived(to, contact.class.Person)) { const employee = await control.findAll( control.ctx, contact.mixin.Employee, { _id: value, active: true }, { limit: 1 } ) return employee[0]?.personUuid != null ? [employee[0].personUuid] : [] } } else if (attr.type._class === core.class.TypeAccountUuid) { return [value] } else if (attr.type._class === core.class.TypePersonId) { const acc = await getAccountBySocialId(control, value) return acc == null ? [] : [acc] } else if (attr.type._class === core.class.ArrOf) { const arrOf = (attr.type as ArrOf>).of if (arrOf._class === core.class.RefTo) { const to = (arrOf as RefTo).to if (hierarchy.isDerived(to, contact.class.Person)) { if (!Array.isArray(value)) { control.ctx.error('Expected array but got non-array value when getting value collaborators', { attr, value }) return [] } if (value.length === 0) return [] if (value.every((it) => it === null)) { control.ctx.error('Null-values array of person refs when getting value collaborators', { attr, value }) } const employees = await control.findAll(control.ctx, contact.mixin.Employee, { _id: { $in: value }, active: true }) return employees.map((e) => e.personUuid).filter(notEmpty) } } else if (arrOf._class === core.class.TypeAccountUuid) { return Array.isArray(value) ? value : [value] } else if (arrOf._class === core.class.TypePersonId) { const socialIds = Array.isArray(value) ? value : [value] const personsBySocialIds = await getEmployeesBySocialIds(control, socialIds) return Object.values(personsBySocialIds) .map((p) => p?.personUuid) .filter(notEmpty) } } return [] } async function getKeyCollaborators ( docClass: Ref>, value: any, field: string, control: TriggerControl ): Promise { if (value !== undefined && value !== null) { const attr = control.hierarchy.findAttribute(docClass, field) if (attr !== undefined) { const val = await getValueCollaborators(value, attr, control) return val } } } /** * @public */ export async function getDocCollaborators ( ctx: MeasureContext, doc: Doc, mixin: ClassCollaborators, control: TriggerControl ): Promise { const collaborators = new Set() if (mixin.allFields !== true) { for (const field of mixin.fields) { const value = (doc as any)[field] const newCollaborators = await ctx.with('getKeyCollaborators', {}, (ctx) => getKeyCollaborators(doc._class, value, field, control) ) if (newCollaborators !== undefined) { for (const newCollaborator of newCollaborators) { collaborators.add(newCollaborator) } } } } else { const attrs = control.hierarchy.getAllAttributes(doc._class) // attr type will be checked into getKeyCollaborators for (const [field] of attrs) { const value = (doc as any)[field] const newCollaborators = await ctx.with('getKeyCollaborators', {}, (ctx) => getKeyCollaborators(doc._class, value, field, control) ) if (newCollaborators !== undefined) { for (const newCollaborator of newCollaborators) { collaborators.add(newCollaborator) } } } } return Array.from(collaborators.values()) } export async function pushInboxNotifications ( ctx: MeasureContext, control: TriggerControl, res: Tx[], receiver: ReceiverInfo, sender: SenderInfo, objectId: Ref, objectClass: Ref>, objectSpace: Ref, contexts: DocNotifyContext[], data: Partial>, _class: Ref>, modifiedOn: Timestamp, types: Ref[], shouldUpdateTimestamp = true, tx?: TxCUD ): Promise | undefined> { const context = getDocNotifyContext(control, contexts, objectId, receiver.account) let docNotifyContextId: Ref if (context === undefined) { docNotifyContextId = await createNotifyContext( ctx, control, objectId, objectClass, objectSpace, receiver, sender.socialId, shouldUpdateTimestamp ? modifiedOn : undefined, tx ) } else { docNotifyContextId = context._id } const notificationData = { user: receiver.account, isViewed: receiver.role === 'GUEST' && receiver.account === readOnlyGuestAccountUuid, docNotifyContext: docNotifyContextId, archived: false, objectId, objectClass, types, ...data } const notificationTx = control.txFactory.createTxCreateDoc(_class, receiver.space, notificationData) res.push(notificationTx) return notificationTx } async function activityInboxNotificationToText ( doc: Data ): Promise<{ title: string, body: string, [key: string]: string }> { let title: string = '' let body: string = '' const params = doc.intlParams ?? {} if (doc.intlParamsNotLocalized != null && Object.keys(doc.intlParamsNotLocalized).length > 0) { for (const key in doc.intlParamsNotLocalized) { const val = doc.intlParamsNotLocalized[key] params[key] = await translate(val, params) } } if (doc.title != null) { title = await translate(doc.title, params) } if (doc.body != null) { body = await translate(doc.body, params) } return { ...params, title, body } } async function commonInboxNotificationToText ( doc: Data ): Promise<{ title: string, body: string, [key: string]: string }> { let title: string = '' let body: string = '' let params = doc.intlParams ?? {} if (doc.props != null) { params = { ...params, ...doc.props } } if (doc.intlParamsNotLocalized != null && Object.keys(doc.intlParamsNotLocalized).length > 0) { for (const key in doc.intlParamsNotLocalized) { const val = doc.intlParamsNotLocalized[key] params[key] = await translate(val, params) } } if (doc.header != null) { title = await translate(doc.header, params) } if (doc.messageHtml != null) { body = stripTags(doc.messageHtml, NOTIFICATION_BODY_SIZE) } if (doc.message != null) { body = await translate(doc.message, params) } return { ...params, title, body } } async function mentionInboxNotificationToText ( doc: Data, control: TriggerControl ): Promise<{ title: string, body: string, [key: string]: string }> { let obj = (await control.findAll(control.ctx, doc.mentionedInClass, { _id: doc.mentionedIn }, { limit: 1 }))[0] if (obj !== undefined) { if (control.hierarchy.isDerived(obj._class, chunter.class.ChatMessage)) { obj = ( await control.findAll( control.ctx, (obj as ChatMessage).attachedToClass, { _id: (obj as ChatMessage).attachedTo }, { limit: 1 } ) )[0] } if (obj !== undefined) { const textPresenter = getTextPresenter(obj._class, control.hierarchy) if (textPresenter !== undefined) { const textPresenterFunc = await getResource(textPresenter.presenter) const title = await textPresenterFunc(obj, control) doc.intlParams = { ...doc.intlParams, title } } } } return await commonInboxNotificationToText(doc) } export async function getTranslatedNotificationContent ( data: Data, _class: Ref>, control: TriggerControl ): Promise<{ title: string, body: string, [key: string]: string }> { if (control.hierarchy.isDerived(_class, notification.class.ActivityInboxNotification)) { return await activityInboxNotificationToText(data as Data) } else if (control.hierarchy.isDerived(_class, notification.class.MentionInboxNotification)) { return await mentionInboxNotificationToText(data as Data, control) } else if (control.hierarchy.isDerived(_class, notification.class.CommonInboxNotification)) { return await commonInboxNotificationToText(data as Data) } return { title: '', body: '' } } /** * @public */ export async function pushActivityInboxNotifications ( ctx: MeasureContext, originTx: TxCUD, control: TriggerControl, res: Tx[], receiver: ReceiverInfo, sender: SenderInfo, object: Doc, docNotifyContexts: DocNotifyContext[], activityMessage: ActivityMessage, types: Ref[], shouldUpdateTimestamp: boolean ): Promise | undefined> { const content = await getNotificationContent(originTx, receiver.employee, sender, object, control) const data: Partial> = { ...content, attachedTo: activityMessage._id, attachedToClass: activityMessage._class } return await pushInboxNotifications( ctx, control, res, receiver, sender, activityMessage.attachedTo, activityMessage.attachedToClass, object.space, docNotifyContexts, data, notification.class.ActivityInboxNotification, activityMessage.modifiedOn, types, shouldUpdateTimestamp, originTx ) } async function createNotifyContext ( ctx: MeasureContext, control: TriggerControl, objectId: Ref, objectClass: Ref>, objectSpace: Ref, receiver: ReceiverInfo, sender: PersonId, updateTimestamp?: Timestamp, tx?: TxCUD ): Promise> { const contextsCache: ContextsCache = control.cache.get(ContextsCacheKey) ?? { contexts: new Map>() } const cacheKey = `${objectId}_${receiver.account}` const cachedId = contextsCache.contexts.get(cacheKey) if (cachedId !== undefined) { if (control.removedMap.has(cachedId)) { contextsCache.contexts.delete(cacheKey) } else { return cachedId } } const lastViewedTimestamp = receiver.role === 'GUEST' && receiver.account === readOnlyGuestAccountUuid ? Number.MAX_VALUE : receiver.socialIds.some((it) => it === sender) ? updateTimestamp : undefined const createTx = control.txFactory.createTxCreateDoc(notification.class.DocNotifyContext, receiver.space, { user: receiver.account, objectId, objectClass, objectSpace, isPinned: false, hidden: false, tx: tx?._id, lastUpdateTimestamp: updateTimestamp, lastViewedTimestamp }) contextsCache.contexts.set(cacheKey, createTx.objectId) control.cache.set(ContextsCacheKey, contextsCache) await ctx.with('apply', {}, () => control.apply(control.ctx, [createTx])) return createTx.objectId } export async function getNotificationTxes ( ctx: MeasureContext, control: TriggerControl, object: Doc, tx: TxCUD, receiver: ReceiverInfo, sender: SenderInfo, params: NotifyParams, docNotifyContexts: DocNotifyContext[], activityMessages: ActivityMessage[], settings: NotificationProviderControl ): Promise { const res: Tx[] = [] for (const message of activityMessages) { const docMessage = message._class === activity.class.DocUpdateMessage ? (message as DocUpdateMessage) : undefined const notifyResult = await isShouldNotifyTx( control, tx, object, receiver, params.isOwn, params.isSpace, settings, docMessage ) if (notifyResult.has(notification.providers.InboxNotificationProvider)) { const types = (notifyResult.get(notification.providers.InboxNotificationProvider) ?? []).map((it) => it._id) const notificationTx = await pushActivityInboxNotifications( ctx, tx, control, res, receiver, sender, object, docNotifyContexts, message, types, params.shouldUpdateTimestamp ) if (notificationTx !== undefined) { const current: AvailableProvidersCache = control.contextCache.get(AvailableProvidersCacheKey) ?? new Map() const providers = Array.from(notifyResult.keys()) if (providers.length > 0) { current.set(notificationTx.objectId, providers) control.contextCache.set('AvailableNotificationProviders', current) } } } else { const context = getDocNotifyContext(control, docNotifyContexts, message.attachedTo, receiver.account) if (context === undefined) { await createNotifyContext( ctx, control, message.attachedTo, message.attachedToClass, object.space, receiver, sender.socialId, params.shouldUpdateTimestamp ? tx.modifiedOn : undefined, tx ) } } } return res } async function updateContextsTimestamp ( ctx: MeasureContext, contexts: DocNotifyContext[], timestamp: Timestamp, control: TriggerControl, modifiedBy: PersonId ): Promise { if (contexts.length === 0) return const res: Tx[] = [] const modifiedByAccount = await getAccountBySocialId(control, modifiedBy) for (const context of contexts) { const isViewed = context.lastViewedTimestamp !== undefined && (context.lastUpdateTimestamp ?? 0) <= context.lastViewedTimestamp const updateTx = control.txFactory.createTxUpdateDoc(context._class, context.space, context._id, { hidden: false, lastUpdateTimestamp: timestamp, ...(isViewed && context.user === modifiedByAccount ? { lastViewedTimestamp: timestamp } : {}) }) res.push(updateTx) } if (res.length > 0) { await ctx.with('apply', {}, () => control.apply(ctx, res)) } } async function removeContexts ( ctx: MeasureContext, contexts: DocNotifyContext[], unsubscribe: AccountUuid[], control: TriggerControl ): Promise { if (contexts.length === 0) return if (unsubscribe.length === 0) return const res: Tx[] = [] for (const context of contexts) { if (!unsubscribe.includes(context.user)) { continue } const removeTx = control.txFactory.createTxRemoveDoc(context._class, context.space, context._id) res.push(removeTx) } await control.apply(control.ctx, res) } export async function createCollabDocInfo ( ctx: MeasureContext, currentRes: Tx[], collaborators: AccountUuid[], control: TriggerControl, tx: TxCUD, object: Doc, activityMessages: ActivityMessage[], params: NotifyParams, unsubscribe: AccountUuid[] = [], cache: Map, Doc> = new Map, Doc>() ): Promise { let res: Tx[] = [] if (tx.space === core.space.DerivedTx) { return res } const docMessages = activityMessages.filter((message) => message.attachedTo === object._id) if (docMessages.length === 0) { if (unsubscribe.length > 0) { const notifyContexts = await control.findAll(ctx, notification.class.DocNotifyContext, { objectId: object._id, user: { $in: unsubscribe } }) await removeContexts(ctx, notifyContexts, unsubscribe, control) } return res } let notifyContexts: DocNotifyContext[] = [] if (!(object._id === tx.objectId && tx._class === core.class.TxCreateDoc)) { notifyContexts = await control.findAll(ctx, notification.class.DocNotifyContext, { objectId: object._id }) } if (notifyContexts.length > 0) { await updateContextsTimestamp(ctx, notifyContexts, tx.modifiedOn, control, tx.modifiedBy) } if (notifyContexts.length > 0 && unsubscribe.length > 0) { await removeContexts(ctx, notifyContexts, unsubscribe, control) } const space = await getObjectSpace(control, object, cache) if (space === undefined) { control.ctx.error('Cannot find space for object', object) Analytics.handleError( new Error(`Cannot find space ${object.space} for objectId ${object._id}, objectClass ${object._class}`) ) return res } cache.set(space._id, space) const filteredCollaborators = !space.private ? collaborators : collaborators.filter( (it) => space.members.includes(it) || currentRes.some((tx) => { if (tx._class === core.class.TxUpdateDoc) { const updateTx = tx as TxUpdateDoc if (updateTx.objectId === space._id) { const added = combineAttributes([updateTx.operations], 'members', '$push', '$each') return added.includes(it) } } return false }) ) const targets = new Set(filteredCollaborators) // user is not collaborator of himself, but we should notify user of changes related to users account (mentions, comments etc) if (control.hierarchy.isDerived(object._class, contact.mixin.Employee)) { const account = (object as Employee).personUuid if (account != null) { targets.add(account) } } if (targets.size === 0) { return res } const receivers = await getReceiversInfo(ctx, Array.from(targets), control) const sender: SenderInfo = await getSenderInfo(ctx, tx.modifiedBy, control) const settings = await getNotificationProviderControl(ctx, control) for (const receiver of receivers) { const targetRes = await getNotificationTxes( ctx, control, object, tx, receiver, sender, params, notifyContexts, docMessages, settings ) res = res.concat(targetRes) } return res } /** * @public */ export function getAddCollaboratTxes ( objectId: Ref, objectClass: Ref>, objectSpace: Ref, control: TriggerControl, collaborators: AccountUuid[] ): TxCreateDoc[] { const res: TxCreateDoc[] = [] for (const collaborator of collaborators) { const tx = control.txFactory.createTxCreateDoc(core.class.Collaborator, objectSpace, { attachedTo: objectId, attachedToClass: objectClass, collaborator, collection: 'collaborators' }) res.push(tx) } return res } async function getTxCollabs ( ctx: MeasureContext, tx: TxCUD, control: TriggerControl, cache: Map, Collaborator[]>, doc: Doc ): Promise<{ added: AccountUuid[] removed: AccountUuid[] result: AccountUuid[] }> { const { hierarchy } = control const mixin = getClassCollaborators(control.modelDb, hierarchy, doc._class) if (mixin === undefined) return { added: [], removed: [], result: [] } if (tx._class === core.class.TxCreateDoc) { const collabs = await getDocCollaborators(ctx, doc, mixin, control) return { added: collabs, removed: [], result: collabs } } if (tx._class === core.class.TxRemoveDoc) { return { added: [], removed: [], result: [] } } if ([core.class.TxUpdateDoc, core.class.TxMixin].includes(tx._class)) { const collaborators = cache.get(doc._id) ?? (await control.findAll(ctx, core.class.Collaborator, { attachedTo: doc._id })) cache.set(doc._id, collaborators) const collabs = collaborators.map((c) => c.collaborator) const ops = isMixinTx(tx) ? tx.attributes : (tx as TxUpdateDoc).operations const newCollaborators = (await getNewCollaborators(ops, mixin, doc._class, control)).filter( (p) => !collabs.includes(p) ) const isSpace = control.hierarchy.isDerived(doc._class, core.class.Space) const removedCollabs = isSpace ? await getRemovedMembers(ops, mixin, (doc as Space)._class, control) : [] const result = [...collabs, ...newCollaborators].filter((p) => !removedCollabs.includes(p)) return { added: newCollaborators, removed: removedCollabs, result } } return { added: [], removed: [], result: [] } } async function getSpaceCollabTxes ( ctx: MeasureContext, control: TriggerControl, doc: Doc, tx: TxCUD, activityMessages: ActivityMessage[], cache: Map, Collaborator[]>, docCache: Map, Doc> ): Promise { if (doc.space === core.space.Space) { return [] } const space = await getObjectSpace(control, doc, docCache) if (space === undefined) return [] docCache.set(space._id, space) const mixin = getClassCollaborators(control.modelDb, control.hierarchy, space._class) if (mixin !== undefined) { const collaborators = cache.get(space._id) ?? (await control.findAll(ctx, core.class.Collaborator, { attachedTo: space._id })) cache.set(space._id, collaborators) const collabs = collaborators.map((c) => c.collaborator) return await createCollabDocInfo( ctx, [], collabs, control, tx, doc, activityMessages, { isSpace: true, isOwn: false, shouldUpdateTimestamp: true }, [], docCache ) } return [] } async function pushCollaboratorsToPublicSpace ( control: TriggerControl, doc: Doc, collaborators: AccountUuid[], cache: Map, Doc> ): Promise { const space = await getObjectSpace(control, doc, cache) if (space === undefined) return [] cache.set(space._id, space) if (control.hierarchy.isDerived(space._class, core.class.SystemSpace)) { return [] } if (space.private) { return [] } return collaborators .filter((it) => !space.members.includes(it)) .map((it) => control.txFactory.createTxUpdateDoc(space._class, space.space, space._id, { $push: { members: it } })) } async function createCollaboratorDoc ( ctx: MeasureContext, tx: TxCreateDoc, control: TriggerControl, activityMessage: ActivityMessage[], cache: Map, Collaborator[]>, docCache: Map, Doc> ): Promise { const res: Tx[] = [] const hierarchy = control.hierarchy const mixin = getClassCollaborators(control.modelDb, hierarchy, tx.objectClass) if (mixin === undefined) { return res } const doc = TxProcessor.createDoc2Doc(tx) const collaborators = await ctx.with('get-collaborators', {}, (ctx) => getDocCollaborators(ctx, doc, mixin, control)) const mixinTx = getAddCollaboratTxes(tx.objectId, tx.objectClass, tx.objectSpace, control, collaborators) res.push(...mixinTx) res.push( ...(await ctx.with('get-space-collabtxes', {}, (ctx) => getSpaceCollabTxes(ctx, control, doc, tx, activityMessage, cache, docCache) )) ) res.push(...(await pushCollaboratorsToPublicSpace(control, doc, collaborators, docCache))) const notificationTxes = await ctx.with('create-collabdocinfo', {}, (ctx) => createCollabDocInfo( ctx, res, collaborators, control, tx, doc, activityMessage, { isOwn: true, isSpace: false, shouldUpdateTimestamp: true }, [], docCache ) ) res.push(...notificationTxes) return res } async function collectionCollabDoc ( ctx: MeasureContext, tx: TxCUD, control: TriggerControl, activityMessages: ActivityMessage[], cache: Map, Collaborator[]>, docCache: Map, Doc>, ignoreCollection: boolean = false ): Promise { let res = await createCollaboratorNotifications( ctx, tx, control, activityMessages, tx, cache, docCache, ignoreCollection ) if (![core.class.TxCreateDoc, core.class.TxRemoveDoc, core.class.TxUpdateDoc].includes(tx._class)) { return res } const { attachedTo, attachedToClass } = tx if (attachedTo === undefined || attachedToClass === undefined) { return res } const mixin = getClassCollaborators(control.modelDb, control.hierarchy, attachedToClass) if (mixin === undefined) { return res } const doc = await ctx.with( 'get-doc', {}, async (ctx) => docCache.get(attachedTo) ?? (await control.findAll(ctx, attachedToClass, { _id: attachedTo }, { limit: 1 }))[0] ) if (doc === undefined) { return res } docCache.set(doc._id, doc) const collaborators = await ctx.with('get-collaborators', {}, (ctx) => getCollaborators(ctx, doc, control, tx, res)) res = res.concat( await ctx.with('create-collab-doc-info', {}, (ctx) => createCollabDocInfo( ctx, res, collaborators, control, tx, doc, activityMessages, { isOwn: false, isSpace: false, shouldUpdateTimestamp: true }, [], docCache ) ) ) return res } async function removeContextNotifications ( control: TriggerControl, notifyContextRefs: Ref[] ): Promise { const inboxNotifications = await control.findAll( control.ctx, notification.class.InboxNotification, { docNotifyContext: { $in: notifyContextRefs } }, { projection: { _id: 1, _class: 1, space: 1 } } ) return inboxNotifications.map((notification) => control.txFactory.createTxRemoveDoc(notification._class, notification.space, notification._id) ) } async function removeCollaboratorDoc (tx: TxRemoveDoc, control: TriggerControl): Promise { const mixin = getClassCollaborators(control.modelDb, control.hierarchy, tx.objectClass) if (mixin === undefined) { return [] } const res: Tx[] = [] const notifyContexts = await control.findAll( control.ctx, notification.class.DocNotifyContext, { objectId: tx.objectId }, { projection: { _id: 1, _class: 1, space: 1 } } ) if (notifyContexts.length === 0) { return [] } const notifyContextRefs = notifyContexts.map(({ _id }) => _id) const txes = await removeContextNotifications(control, notifyContextRefs) res.push(...txes) notifyContexts.forEach((context) => { res.push(control.txFactory.createTxRemoveDoc(context._class, context.space, context._id)) }) return res } async function getNewCollaborators ( ops: DocumentUpdate | MixinUpdate, mixin: ClassCollaborators, docClass: Ref>, control: TriggerControl ): Promise { const newCollaborators = new Set() if (ops.$push !== undefined) { for (const key in ops.$push) { if (mixin.fields.includes(key as any) || mixin.allFields === true) { let value = (ops.$push as any)[key] if (typeof value !== 'string') { value = value.$each } const newCollabs = await getKeyCollaborators(docClass, value, key, control) if (newCollabs !== undefined) { for (const newCollab of newCollabs) { newCollaborators.add(newCollab) } } } } } for (const key in ops) { if (key.startsWith('$')) continue if (mixin.fields.includes(key as any) || mixin.allFields === true) { const value = (ops as any)[key] const newCollabs = await getKeyCollaborators(docClass, value, key, control) if (newCollabs !== undefined) { for (const newCollab of newCollabs) { newCollaborators.add(newCollab) } } } } return Array.from(newCollaborators) } async function getRemovedMembers ( ops: DocumentUpdate | MixinUpdate, mixin: ClassCollaborators, docClass: Ref>, control: TriggerControl ): Promise { const removedCollaborators: AccountUuid[] = [] if (ops.$pull !== undefined && 'members' in ops.$pull) { const key = 'members' if (mixin.fields.includes(key as any) || mixin.allFields === true) { let value = (ops.$pull as any)[key] if (typeof value !== 'string') { value = value.$in } const collabs = await getKeyCollaborators(docClass, value, key, control) if (collabs !== undefined) { removedCollaborators.push(...collabs) } } } return Array.from(new Set(removedCollaborators)) } async function createSyncCollaboratorsTxes ( ctx: MeasureContext, control: TriggerControl, cache: Map, Collaborator[]>, objectId: Ref, objectClass: Ref>, objectSpace: Ref, added: AccountUuid[], removed: AccountUuid[] ): Promise<{ txes: Tx[], toAdd: AccountUuid[] }> { const res: Tx[] = [] let currentCollaborators = cache.get(objectId) ?? (await control.findAll(ctx, core.class.Collaborator, { attachedTo: objectId })) const toAdd = added.filter((p) => currentCollaborators.find((c) => c.collaborator === p) === undefined) if (toAdd.length === 0 && removed.length === 0) return { txes: res, toAdd: [] } if (toAdd.length > 0) { const txes = getAddCollaboratTxes(objectId, objectClass, objectSpace, control, toAdd) res.push(...txes) txes.forEach((tx) => { const collab = TxProcessor.createDoc2Doc(tx) currentCollaborators.push(collab) }) } if (removed.length > 0) { const toRemove: Collaborator[] = [] const collabs: Collaborator[] = [] for (const collab of currentCollaborators) { if (removed.includes(collab.collaborator)) { toRemove.push(collab) } else { collabs.push(collab) } } for (const removedCollab of toRemove) { res.push(control.txFactory.createTxRemoveDoc(core.class.Collaborator, removedCollab.space, removedCollab._id)) } currentCollaborators = collabs } cache.set(objectId, currentCollaborators) return { txes: res, toAdd } } async function updateCollaboratorDoc ( ctx: MeasureContext, tx: TxUpdateDoc | TxMixin, control: TriggerControl, activityMessages: ActivityMessage[], cache: Map, Collaborator[]>, docCache: Map, Doc> ): Promise { const hierarchy = control.hierarchy let res: Tx[] = [] const mixin = getClassCollaborators(control.modelDb, hierarchy, tx.objectClass) if (mixin === undefined) return [] const doc = await ctx.with( 'find-doc', { _class: tx.objectClass }, async (ctx) => docCache.get(tx.objectId) ?? (await control.findAll(ctx, tx.objectClass, { _id: tx.objectId }, { limit: 1 }))[0] ) if (doc === undefined) return [] docCache.set(doc._id, doc) const params: NotifyParams = { isOwn: true, isSpace: false, shouldUpdateTimestamp: true } // we should handle change field and subscribe new collaborators const collabsInfo = await ctx.with('get-tx-collaborators', {}, (ctx) => getTxCollabs(ctx, tx, control, cache, doc)) const sync = await createSyncCollaboratorsTxes( ctx, control, cache, doc._id, doc._class, doc.space, collabsInfo.added, collabsInfo.removed ) res.push(...sync.txes) res = res.concat( await ctx.with('create-collab-docinfo', {}, (ctx) => createCollabDocInfo( ctx, res, collabsInfo.result, control, tx, doc, activityMessages, params, collabsInfo.removed, docCache ) ) ) res = res.concat( await ctx.with('get-space-collabtxes', {}, (ctx) => getSpaceCollabTxes(ctx, control, doc, tx, activityMessages, cache, docCache) ) ) res = res.concat( await ctx.with('update-notify-context-space', {}, (ctx) => updateNotifyContextsSpace(ctx, control, tx)) ) return res } /** * @public */ export async function OnAttributeCreate (txes: Tx[], control: TriggerControl): Promise { const result: Tx[] = [] for (const tx of txes) { const attribute = TxProcessor.createDoc2Doc(tx as TxCreateDoc) const group = ( await control.modelDb.findAll(notification.class.NotificationGroup, { objectClass: attribute.attributeOf }) )[0] if (group === undefined) { continue } const isCollection: boolean = core.class.Collection === attribute.type._class const objectClass = !isCollection ? attribute.attributeOf : (attribute.type as Collection).of const txClasses = !isCollection ? [control.hierarchy.isMixin(attribute.attributeOf) ? core.class.TxMixin : core.class.TxUpdateDoc] : [core.class.TxCreateDoc, core.class.TxRemoveDoc] const data: Data = { attribute: attribute._id, group: group._id, field: attribute.name, generated: true, objectClass, txClasses, hidden: false, defaultEnabled: false, templates: { textTemplate: '{body}', htmlTemplate: '

{body}

{link}

', subjectTemplate: '{doc} updated' }, label: attribute.label } if (isCollection) { data.attachedToClass = attribute.attributeOf } const id = `${notification.class.NotificationType}_${attribute.attributeOf}_${attribute.name}` as Ref result.push(control.txFactory.createTxCreateDoc(notification.class.NotificationType, core.space.Model, data, id)) } return result } /** * @public */ export async function OnAttributeUpdate (txes: Tx[], control: TriggerControl): Promise { const result: Tx[] = [] for (const tx of txes) { const ctx = tx as TxUpdateDoc if (ctx.operations.hidden === undefined) { continue } const type = ( await control.findAll(control.ctx, notification.class.NotificationType, { attribute: ctx.objectId }) )[0] if (type === undefined) { continue } result.push( control.txFactory.createTxUpdateDoc(type._class, type.space, type._id, { hidden: ctx.operations.hidden }) ) } return result } async function updateCollaborators ( ctx: MeasureContext, control: TriggerControl, tx: TxCUD, cache: Map, Collaborator[]> ): Promise { if (tx._class !== core.class.TxUpdateDoc && tx._class !== core.class.TxMixin) return [] const hierarchy = control.hierarchy if (hierarchy.classHierarchyMixin(tx.objectClass, activity.mixin.ActivityDoc) === undefined) return [] const mixin = getClassCollaborators(control.modelDb, hierarchy, tx.objectClass) if (mixin === undefined) return [] const doc = (await control.findAll(ctx, tx.objectClass, { _id: tx.objectId }, { limit: 1 }))[0] if (doc === undefined) return [] const collabsResult = await getTxCollabs(ctx, tx, control, cache, doc) if (collabsResult.added.length === 0 && collabsResult.removed.length === 0) return [] const res: Tx[] = [] const { txes: collabTxes } = await createSyncCollaboratorsTxes( ctx, control, cache, doc._id, doc._class, doc.space, collabsResult.added, collabsResult.removed ) res.push(...collabTxes) const contexts = await control.findAll(control.ctx, notification.class.DocNotifyContext, { objectId: tx.attachedTo }) const addedInfo = collabsResult.added.length > 0 ? await getReceiversInfo(ctx, collabsResult.added, control) : [] for (const info of addedInfo) { const context = getDocNotifyContext(control, contexts, doc._id, info.account) if (context !== undefined) { if (context.hidden) { res.push(control.txFactory.createTxUpdateDoc(context._class, context.space, context._id, { hidden: false })) } } await createNotifyContext(ctx, control, doc._id, doc._class, doc.space, info, tx.modifiedBy, undefined, tx) } if (collabsResult.removed.length > 0) { await removeContexts(ctx, contexts, collabsResult.removed, control) } return res } export async function createCollaboratorNotifications ( ctx: MeasureContext, tx: TxCUD, control: TriggerControl, activityMessages: ActivityMessage[], originTx?: TxCUD, cache: Map, Collaborator[]> = new Map, Collaborator[]>(), docCache: Map, Doc> = new Map, Doc>(), ignoreCollection: boolean = false ): Promise { if (tx.space === core.space.DerivedTx) { // do not forgot update collaborators for derived tx return await ctx.with('updateDerivedCollaborators', {}, (ctx) => updateCollaborators(ctx, control, tx, cache)) } if (activityMessages.length === 0) { return [] } if (tx.attachedTo !== undefined && !ignoreCollection) { return await ctx.with('collectionCollabDoc', {}, (ctx) => collectionCollabDoc(ctx, tx as TxCUD, control, activityMessages, cache, docCache, true) ) } switch (tx._class) { case core.class.TxCreateDoc: { return await ctx.with('createCollaboratorDoc', {}, (ctx) => createCollaboratorDoc(ctx, tx as TxCreateDoc, control, activityMessages, cache, docCache) ) } case core.class.TxUpdateDoc: case core.class.TxMixin: { const res = await ctx.with('updateCollaboratorDoc', {}, (ctx) => updateCollaboratorDoc(ctx, tx as TxUpdateDoc, control, activityMessages, cache, docCache) ) return res } } return [] } /** * @public */ export async function removeDocInboxNotifications (_id: Ref, control: TriggerControl): Promise { const inboxNotifications = await control.findAll(control.ctx, notification.class.InboxNotification, { attachedTo: _id }) return inboxNotifications.map((inboxNotification) => control.txFactory.createTxRemoveDoc( notification.class.InboxNotification, inboxNotification.space, inboxNotification._id ) ) } export async function getCollaborators ( ctx: MeasureContext, doc: Doc, control: TriggerControl, tx: TxCUD, res: Tx[] ): Promise { const mixin = getClassCollaborators(control.modelDb, control.hierarchy, doc._class) if (mixin === undefined) { return [] } const collaborators = await control.findAll(ctx, core.class.Collaborator, { attachedTo: doc._id }) if (collaborators.length > 0) { return collaborators.map((p) => p.collaborator) } else { const collaborators = await getDocCollaborators(ctx, doc, mixin, control) res.push(...getAddCollaboratTxes(tx.objectId, tx.objectClass, tx.objectSpace, control, collaborators)) return collaborators } } function getDocNotifyContext ( control: TriggerControl, contexts: DocNotifyContext[], objectId: Ref, user: AccountUuid ): DocNotifyContext | undefined { const context = contexts.find((it) => it.objectId === objectId && it.user === user) if (context !== undefined) { return context } const txes = [...control.txes, ...control.ctx.contextData.broadcast.txes] as TxCUD[] for (const tx of txes) { if (tx._class === core.class.TxCreateDoc && tx.objectClass === notification.class.DocNotifyContext) { const doc = TxProcessor.createDoc2Doc(tx as TxCreateDoc) if (doc.objectId === objectId && doc.user === user) { return doc } } } return undefined } async function OnActivityMessageRemove (message: ActivityMessage, control: TriggerControl): Promise { if (control.removedMap.has(message.attachedTo)) { return [] } const contexts = await control.findAll(control.ctx, notification.class.DocNotifyContext, { objectId: message.attachedTo, lastUpdateTimestamp: message.createdOn }) if (contexts.length === 0) return [] const lastMessage = ( await control.findAll( control.ctx, activity.class.ActivityMessage, { attachedTo: message.attachedTo, space: message.space }, { sort: { createdOn: SortingOrder.Descending }, limit: 1 } ) )[0] if (lastMessage === undefined) return [] const res: Tx[] = [] for (const context of contexts) { const tx = control.txFactory.createTxUpdateDoc(context._class, context.space, context._id, { lastUpdateTimestamp: lastMessage.createdOn ?? lastMessage.modifiedOn }) res.push(tx) } return res } async function OnEmployeeDeactivate (txes: TxCUD[], control: TriggerControl): Promise { const result: Tx[] = [] for (const tx of txes) { const actualTx = tx if (core.class.TxMixin !== actualTx._class) { return [] } const ctx = actualTx as TxMixin if (ctx.mixin !== contact.mixin.Employee || ctx.attributes.active !== false) { return [] } const person = (await control.findAll(control.ctx, contact.class.Person, { _id: ctx.objectId }))[0] if (person?.personUuid === undefined) return [] const res: Tx[] = [] const subscriptions = await control.findAll(control.ctx, notification.class.PushSubscription, { user: person.personUuid as AccountUuid }) for (const sub of subscriptions) { res.push(control.txFactory.createTxRemoveDoc(sub._class, sub.space, sub._id)) } } return result } async function OnDocRemove (txes: TxCUD[], control: TriggerControl): Promise { const ltxes = txes.filter((it) => it._class === core.class.TxRemoveDoc) as TxRemoveDoc[] const res: Tx[] = [] for (const tx of ltxes) { if (control.hierarchy.isDerived(tx.objectClass, activity.class.ActivityMessage)) { const message = control.removedMap.get(tx.objectId) as ActivityMessage | undefined if (message !== undefined) { const txes = await OnActivityMessageRemove(message, control) res.push(...txes) } } else if (control.hierarchy.isDerived(tx.objectClass, notification.class.DocNotifyContext)) { const contextsCache: ContextsCache | undefined = control.cache.get(ContextsCacheKey) if (contextsCache !== undefined) { for (const [key, value] of contextsCache.contexts.entries()) { if (value === tx.objectId) { contextsCache.contexts.delete(key) } } } res.push(...(await removeContextNotifications(control, [tx.objectId as Ref]))) } res.push(...(await removeCollaboratorDoc(tx, control))) } return res } export * from './push' export * from './types' export * from './utils' // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export default async () => ({ trigger: { OnAttributeCreate, OnAttributeUpdate, OnDocRemove, OnEmployeeDeactivate, PushNotificationsHandler }, function: { IsUserEmployeeInFieldValueTypeMatch: isUserEmployeeInFieldValueTypeMatch, MentionTypeMatch: mentionTypeMatch } })