// // 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, DocUpdateMessage } from '@hcengineering/activity' import { Analytics } from '@hcengineering/analytics' import chunter, { ChatMessage } from '@hcengineering/chunter' import contact, { Employee, formatName, includesAny, Person, SocialIdentity, SocialIdentityRef } from '@hcengineering/contact' import core, { AccountUuid, Class, concatLink, Doc, DocumentUpdate, groupByArray, Hierarchy, Markup, matchQuery, type MeasureContext, MixinUpdate, notEmpty, PersonId, Ref, Space, Tx, TxCreateDoc, TxCUD, TxMixin, TxProcessor, TxUpdateDoc } from '@hcengineering/core' import notification, { NotificationContent, notificationId, NotificationProvider, type NotificationProviderSetting, NotificationType, type NotificationTypeSetting } from '@hcengineering/notification' import { getMetadata, getResource, IntlString, translate } from '@hcengineering/platform' import { getPersonSpaces } from '@hcengineering/server-contact' import serverCore, { TriggerControl } from '@hcengineering/server-core' import serverNotification, { HTMLPresenter, NotificationPresenter, ReceiverInfo, SenderInfo, TextPresenter } from '@hcengineering/server-notification' import serverView from '@hcengineering/server-view' import { extractReferences, markupToJSON, Reference } from '@hcengineering/text-core' import { encodeObjectURI } from '@hcengineering/view' import { workbenchId } from '@hcengineering/workbench' import { NotifyResult } from './types' /** * @public */ export function isUserEmployeeInFieldValueTypeMatch ( _: Tx, doc: Doc, person: Ref, socialIds: PersonId[], type: NotificationType, control: TriggerControl ): boolean { if (type.field === undefined) return false const value = (doc as any)[type.field] if (value == null) return false if (value === person) return true if (Array.isArray(value)) { return includesAny(value, socialIds) } else { return socialIds.includes(value) } } export const mentionTypeMatch = ( tx: TxCreateDoc, doc: Doc, person: Ref, socialIds: PersonId[], type: NotificationType, control: TriggerControl, account: AccountUuid ): boolean => { const hierarchy = control.hierarchy if (tx._class !== core.class.TxCreateDoc) return false if (!hierarchy.isDerived(tx.objectClass, chunter.class.ChatMessage)) return false const message = TxProcessor.createDoc2Doc(tx) const content: string = message.message const references: Reference[] = control.contextCache.get(`${message._id}_references`) ?? extractReferences(markupToJSON(content)) control.contextCache.set(`${message._id}_references`, references) if (references.length === 0) return false if (references.some(({ objectId }) => objectId === contact.mention.Everyone)) return true if (references.some(({ objectId }) => objectId === contact.mention.Here)) { const isOnline = Array.from(control.userStatusMap.values()).some(({ user, online }) => user === account && online) if (isOnline) return true } return references.some(({ objectId }) => objectId === person) } /** * @public */ export function isUserInFieldValueTypeMatch (_: Tx, doc: Doc, user: PersonId[], type: NotificationType): boolean { if (type.field === undefined) { return false } const value = (doc as any)[type.field] if (value === undefined) { return false } return Array.isArray(value) ? user.some((it) => value.includes(it)) : user.includes(value) } export function replaceAll (str: string, find: string, replace: string): string { return str.replace(new RegExp(escapeRegExp(find), 'g'), replace) } function escapeRegExp (str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } export function getAllowedProviders ( control: TriggerControl, socialIds: PersonId[], type: NotificationType, notificationControl: NotificationProviderControl ): Ref[] { const result: Ref[] = [] const providers = control.modelDb.findAllSync(notification.class.NotificationProvider, {}) for (const provider of providers) { const allowed = isAllowed(control, socialIds, type, provider, notificationControl) if (allowed) { result.push(provider._id) } } return result } export function isAllowed ( control: TriggerControl, receiverIds: PersonId[], type: NotificationType, provider: NotificationProvider, notificationControl: NotificationProviderControl ): boolean { const providerSettings = (notificationControl.byProvider.get(provider._id) ?? []).filter(({ createdBy }) => createdBy !== undefined ? receiverIds.includes(createdBy) : false ) if (providerSettings.length > 0 && providerSettings.every((s) => !s.enabled)) { return false } if (providerSettings.length === 0 && !provider.defaultEnabled) { return false } const providerDefaults = control.modelDb.findAllSync(notification.class.NotificationProviderDefaults, {}) if (providerDefaults.some((it) => it.provider === provider._id && it.ignoredTypes.includes(type._id))) { return false } const setting = (notificationControl.settingsByProvider.get(provider._id) ?? []).find( (it) => it.type === type._id && it.createdBy !== undefined && receiverIds.includes(it.createdBy) ) if (setting !== undefined) { return setting.enabled } if (providerDefaults.some((it) => it.provider === provider._id && it.enabledTypes.includes(type._id))) { return true } if (type === undefined) return false return type.defaultEnabled } export async function isShouldNotifyTx ( control: TriggerControl, tx: TxCUD, object: Doc, receiver: ReceiverInfo, isOwn: boolean, isSpace: boolean, notificationControl: NotificationProviderControl, docUpdateMessage?: DocUpdateMessage ): Promise { const types = getMatchedTypes(control, tx, isOwn, isSpace, docUpdateMessage?.attributeUpdates?.attrKey) const modifiedByPersonId = tx.modifiedBy const result = new Map, NotificationType[]>() let providers: NotificationProvider[] = control.modelDb.findAllSync(notification.class.NotificationProvider, {}) if (getMetadata(serverNotification.metadata.InboxOnlyNotifications) === true) { providers = providers.filter((it) => it._id === notification.providers.InboxNotificationProvider) } for (const type of types) { if (type.allowedForAuthor !== true && receiver.socialIds.includes(modifiedByPersonId)) { continue } if (control.hierarchy.hasMixin(type, serverNotification.mixin.TypeMatch)) { const mixin = control.hierarchy.as(type, serverNotification.mixin.TypeMatch) if (mixin.func !== undefined) { const f = await getResource(mixin.func) let res = f(tx, object, receiver.employee, receiver.socialIds, type, control, receiver.account) if (res instanceof Promise) { res = await res } if (!res) continue } } for (const provider of providers) { const allowed = isAllowed(control, receiver.socialIds, type, provider, notificationControl) if (allowed) { const cur = result.get(provider._id) ?? [] result.set(provider._id, [...cur, type]) } } } return result } function getMatchedTypes ( control: TriggerControl, tx: TxCUD, isOwn: boolean, isSpace: boolean, field?: string ): NotificationType[] { const allTypes = control.modelDb .findAllSync(notification.class.NotificationType, { ...(field !== undefined ? { field } : {}) }) .filter((p) => (isSpace ? p.spaceSubscribe === true : p.spaceSubscribe !== true)) const filtered: NotificationType[] = [] for (const type of allTypes) { if (isTypeMatched(control, type, tx, isOwn)) { filtered.push(type) } } return filtered } function isTypeMatched (control: TriggerControl, type: NotificationType, tx: TxCUD, isOwn: boolean): boolean { const h = control.hierarchy const targetClass = h.getBaseClass(type.objectClass) if (type.onlyOwn === true && !isOwn) return false if (!type.txClasses.includes(tx._class)) return false if (!control.hierarchy.isDerived(h.getBaseClass(tx.objectClass), targetClass)) return false if (tx.attachedToClass !== undefined && type.attachedToClass !== undefined) { if (!control.hierarchy.isDerived(h.getBaseClass(tx.attachedToClass), h.getBaseClass(type.attachedToClass))) { return false } } if (type.field !== undefined) { if (tx._class === core.class.TxUpdateDoc) { if (!fieldUpdated(type.field, (tx as TxUpdateDoc).operations)) return false } if (tx._class === core.class.TxMixin) { if (!fieldUpdated(type.field, (tx as TxMixin).attributes)) return false } } if (type.txMatch !== undefined) { const res = matchQuery([tx], type.txMatch, tx._class, control.hierarchy, true) if (res.length === 0) return false } return true } function fieldUpdated (field: string, ops: DocumentUpdate | MixinUpdate): boolean { if ((ops as any)[field] !== undefined) return true if ((ops.$pull as any)?.[field] !== undefined) return true if ((ops.$push as any)?.[field] !== undefined) return true return false } export async function updateNotifyContextsSpace ( ctx: MeasureContext, control: TriggerControl, tx: TxUpdateDoc | TxMixin ): Promise { if (tx._class !== core.class.TxUpdateDoc) { return [] } const updateTx = tx as TxUpdateDoc if (updateTx.operations.space === undefined) { return [] } const notifyContexts = await control.findAll(ctx, notification.class.DocNotifyContext, { objectId: tx.objectId }) return notifyContexts.map((value) => control.txFactory.createTxUpdateDoc(value._class, value.space, value._id, { objectSpace: updateTx.operations.space }) ) } export function isMixinTx (tx: TxCUD): tx is TxMixin { return tx._class === core.class.TxMixin } export function getHTMLPresenter (_class: Ref>, hierarchy: Hierarchy): HTMLPresenter | undefined { return hierarchy.classHierarchyMixin(_class, serverNotification.mixin.HTMLPresenter) } export function getTextPresenter (_class: Ref>, hierarchy: Hierarchy): TextPresenter | undefined { return hierarchy.classHierarchyMixin(_class, serverNotification.mixin.TextPresenter) } async function getSenderName (control: TriggerControl, sender: SenderInfo): Promise { if (sender.socialId === core.account.System || sender.socialId === core.account.ConfigUser) { return await translate(core.string.System, {}) } const { person } = sender if (person === undefined) { console.error('Cannot find person', { socialId: sender.socialId }) Analytics.handleError(new Error(`Cannot find person ${sender.socialId}`)) return '' } return formatName(person.name, control.branding?.lastNameFirst) } async function getFallbackNotificationFullfillment ( object: Doc, originTx: TxCUD, control: TriggerControl, sender: SenderInfo ): Promise { const title: IntlString = notification.string.CommonNotificationTitle let body: IntlString = notification.string.CommonNotificationBody const intlParams: Record = {} const intlParamsNotLocalized: Record = {} const textPresenter = getTextPresenter(object._class, control.hierarchy) if (textPresenter !== undefined) { const textPresenterFunc = await getResource(textPresenter.presenter) intlParams.title = await textPresenterFunc(object, control) } intlParams.senderName = await getSenderName(control, sender) if (originTx._class === core.class.TxUpdateDoc) { const updateTx = originTx as TxUpdateDoc const attributes = control.hierarchy.getAllAttributes(object._class) for (const attrName in updateTx.operations) { if (!Object.prototype.hasOwnProperty.call(updateTx.operations, attrName)) { continue } const attr = attributes.get(attrName) if (attr !== null && attr !== undefined) { intlParamsNotLocalized.property = attr.label if (attr.type._class === core.class.TypeString) { body = notification.string.CommonNotificationChangedProperty intlParams.newValue = (updateTx.operations as any)[attrName]?.toString() } else { body = notification.string.CommonNotificationChanged } } break } } else if (originTx.attachedToClass !== undefined && originTx._class === core.class.TxCreateDoc) { const clazz = control.hierarchy.getClass(originTx.objectClass) const label = clazz.pluralLabel ?? clazz.label if (label !== undefined) { intlParamsNotLocalized.collection = clazz.pluralLabel ?? clazz.label body = notification.string.CommonNotificationCollectionAdded } } else if (originTx.attachedToClass !== undefined && originTx._class === core.class.TxRemoveDoc) { const clazz = control.hierarchy.getClass(originTx.objectClass) const label = clazz.pluralLabel ?? clazz.label if (label !== undefined) { intlParamsNotLocalized.collection = clazz.pluralLabel ?? clazz.label body = notification.string.CommonNotificationCollectionRemoved } } return { title, body, intlParams, intlParamsNotLocalized } } function getNotificationPresenter (_class: Ref>, hierarchy: Hierarchy): NotificationPresenter | undefined { return hierarchy.classHierarchyMixin(_class, serverNotification.mixin.NotificationPresenter) } export async function getNotificationContent ( originTx: TxCUD, receiver: Ref, sender: SenderInfo, object: Doc, control: TriggerControl ): Promise { let { title, body, intlParams, intlParamsNotLocalized } = await getFallbackNotificationFullfillment( object, originTx, control, sender ) let data: Markup | undefined const notificationPresenter = getNotificationPresenter(originTx.objectClass, control.hierarchy) if (notificationPresenter !== undefined) { const getFuillfillmentParams = await getResource(notificationPresenter.presenter) const updateParams = await getFuillfillmentParams(object, originTx, receiver, control) title = updateParams.title body = updateParams.body data = updateParams.data intlParams = { ...intlParams, ...updateParams.intlParams } if (updateParams.intlParamsNotLocalized != null) { intlParamsNotLocalized = { ...intlParamsNotLocalized, ...updateParams.intlParamsNotLocalized } } } const content: NotificationContent = { title, body, data, intlParams } if (intlParamsNotLocalized !== undefined) { content.intlParamsNotLocalized = intlParamsNotLocalized } return content } export async function getReceiversInfo ( ctx: MeasureContext, accounts: AccountUuid[], control: TriggerControl ): Promise { if (accounts.length === 0) return [] const employees: Pick[] = await control.findAll( ctx, contact.mixin.Employee, { personUuid: { $in: accounts }, active: true }, { projection: { _id: 1, personUuid: 1, role: 1 } } ) if (employees.length === 0) return [] const spaces = await getPersonSpaces(control) if (spaces.length === 0) return [] const socialIds: Pick[] = await control.findAll( ctx, contact.class.SocialIdentity, { attachedTo: { $in: employees.map((it) => it._id) } }, { projection: { _id: 1, attachedTo: 1 } } ) const employeeByAccount = new Map(employees.map((it) => [it.personUuid, it])) const spaceByPerson = new Map(spaces.map((it) => [it.person, it])) const socialIdsByEmployee = groupByArray(socialIds, (it) => it.attachedTo) return accounts .map((account) => { const employee = employeeByAccount.get(account) if (employee === undefined) return undefined const space = spaceByPerson.get(employee._id) if (space === undefined) return undefined const info: ReceiverInfo = { employee: employee._id, role: employee.role, space: space._id, account, socialIds: socialIdsByEmployee.get(employee._id)?.map((it) => it._id) ?? [] } return info }) .filter(notEmpty) } export async function getSenderInfo ( ctx: MeasureContext, socialId: PersonId, control: TriggerControl ): Promise { const controlAccount = control.ctx.contextData.account let account: AccountUuid | undefined = control.ctx.contextData.socialStringsToUsers.get(socialId)?.accontUuid if (account == null && controlAccount.socialIds.includes(socialId)) { account = controlAccount.uuid } if (account != null) { return { socialId, person: (await control.findAll(ctx, contact.class.Person, { personUuid: account }))[0] } } const socialIdentity = ( await control.findAll( control.ctx, contact.class.SocialIdentity, { _id: socialId as SocialIdentityRef }, { limit: 1, projection: { _id: 1, attachedTo: 1 } } ) )[0] if (socialIdentity === undefined) { return { socialId } } return { socialId, person: (await control.findAll(ctx, contact.class.Person, { _id: socialIdentity.attachedTo }))[0] } } export async function getNotificationLink ( control: TriggerControl, doc: Doc, message?: Ref ): Promise { const linkProviders = control.modelDb.findAllSync(serverView.mixin.ServerLinkIdProvider, {}) const provider = linkProviders.find(({ _id }) => _id === doc._class) let id: string = doc._id if (provider !== undefined) { const encodeFn = await getResource(provider.encode) id = await encodeFn(doc, control) } let thread: string | undefined if (control.hierarchy.isDerived(doc._class, activity.class.ActivityMessage)) { const id = (doc as ActivityMessage)._id if (message === undefined) { message = id } else { thread = id } } const front = control.branding?.front ?? getMetadata(serverCore.metadata.FrontUrl) ?? '' const path = [workbenchId, control.workspace.url, notificationId, encodeObjectURI(id, doc._class), thread] .filter((x): x is string => x !== undefined) .map((p) => encodeURIComponent(p)) .join('/') const link = concatLink(front, path) return message !== undefined ? `${link}?message=${message}` : link } export async function messageToMarkup (control: TriggerControl, message: ActivityMessage): Promise { const { hierarchy } = control if (hierarchy.isDerived(message._class, chunter.class.ChatMessage)) { const chatMessage = message as ChatMessage return chatMessage.message } else { const resource = getTextPresenter(message._class, control.hierarchy) if (resource !== undefined) { const fn = await getResource(resource.presenter) const textData = await fn(message, control) if (textData !== undefined && textData !== '') { return textData } } } return undefined } export class NotificationProviderControl { public byProvider: Map, NotificationProviderSetting[]> public settingsByProvider: Map, NotificationTypeSetting[]> constructor ( readonly providersSettings: NotificationProviderSetting[], readonly typesSettings: NotificationTypeSetting[] ) { this.byProvider = groupByArray(providersSettings, (it) => it.attachedTo) this.settingsByProvider = groupByArray(typesSettings, (it) => it.attachedTo) } } const notificationProvidersKey = 'notification_provider_settings' const typesSettingsKey = 'notification_type_settings' export async function getNotificationProviderControl ( ctx: MeasureContext, control: TriggerControl ): Promise { let providersSettings: NotificationProviderSetting[] = control.contextCache.get(notificationProvidersKey) if (providersSettings === undefined) { providersSettings = await control.queryFind(ctx, notification.class.NotificationProviderSetting, { space: core.space.Workspace }) control.contextCache.set(notificationProvidersKey, providersSettings) } let typesSettings: NotificationTypeSetting[] = control.contextCache.get(typesSettingsKey) if (typesSettings === undefined) { typesSettings = await control.queryFind(ctx, notification.class.NotificationTypeSetting, { space: core.space.Workspace }) control.contextCache.set(typesSettingsKey, typesSettings) } return new NotificationProviderControl(providersSettings, typesSettings) } export async function getObjectSpace (control: TriggerControl, doc: Doc, cache: Map, Doc>): Promise { return control.hierarchy.isDerived(doc._class, core.class.Space) ? (doc as Space) : ((cache.get(doc.space) as Space) ?? (await control.findAll(control.ctx, core.class.Space, { _id: doc.space }, { limit: 1 }))[0]) }