mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-13 13:17:45 +02:00
UBER-845: Add NotificationPresenter to send rich text notifications (#3729)
Signed-off-by: Maxim Karmatskikh <mkarmatskih@gmail.com>
This commit is contained in:
@@ -43,11 +43,12 @@ import core, {
|
||||
TxRemoveDoc,
|
||||
TxUpdateDoc
|
||||
} from '@hcengineering/core'
|
||||
import notification, { Collaborators, NotificationType } from '@hcengineering/notification'
|
||||
import { getMetadata } from '@hcengineering/platform'
|
||||
import notification, { Collaborators, NotificationType, NotificationContent } from '@hcengineering/notification'
|
||||
import { getMetadata, IntlString } from '@hcengineering/platform'
|
||||
import serverCore, { TriggerControl } from '@hcengineering/server-core'
|
||||
import { getDocCollaborators, getMixinTx, pushNotification } from '@hcengineering/server-notification-resources'
|
||||
import { workbenchId } from '@hcengineering/workbench'
|
||||
import { stripTags } from '@hcengineering/text'
|
||||
import { getBacklinks } from './backlinks'
|
||||
|
||||
function getCreateBacklinksTxes (
|
||||
@@ -442,7 +443,7 @@ export async function OnMessageSent (tx: Tx, control: TriggerControl): Promise<T
|
||||
|
||||
if (anotherPerson == null) return []
|
||||
|
||||
pushNotification(control, res, sender, channel, dmCreationTx, docUpdates, anotherPerson)
|
||||
await pushNotification(control, res, sender, channel, dmCreationTx, docUpdates, anotherPerson)
|
||||
} else if (docUpdate.hidden) {
|
||||
res.push(control.txFactory.createTxUpdateDoc(docUpdate._class, docUpdate.space, docUpdate._id, { hidden: false }))
|
||||
}
|
||||
@@ -536,6 +537,40 @@ export async function IsThreadMessage (
|
||||
return space !== undefined
|
||||
}
|
||||
|
||||
const NOTIFICATION_BODY_SIZE = 50
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export async function getChunterNotificationContent (
|
||||
doc: Doc,
|
||||
tx: TxCUD<Doc>,
|
||||
target: Ref<Account>,
|
||||
control: TriggerControl
|
||||
): Promise<NotificationContent> {
|
||||
const title: IntlString = chunter.string.DirectNotificationTitle
|
||||
let body: IntlString = chunter.string.Message
|
||||
const intlParams: Record<string, string | number> = {}
|
||||
|
||||
if (tx._class === core.class.TxCollectionCUD) {
|
||||
const ptx = tx as TxCollectionCUD<Doc, AttachedDoc>
|
||||
if (ptx.tx._class === core.class.TxCreateDoc) {
|
||||
if (ptx.tx.objectClass === chunter.class.Message) {
|
||||
const createTx = ptx.tx as TxCreateDoc<Message>
|
||||
const message = createTx.attributes.content
|
||||
const plainTextMessage = stripTags(message, NOTIFICATION_BODY_SIZE)
|
||||
intlParams.message = plainTextMessage
|
||||
body = chunter.string.DirectNotificationBody
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
body,
|
||||
intlParams
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
export default async () => ({
|
||||
trigger: {
|
||||
@@ -547,6 +582,7 @@ export default async () => ({
|
||||
CommentRemove,
|
||||
ChannelHTMLPresenter: channelHTMLPresenter,
|
||||
ChannelTextPresenter: channelTextPresenter,
|
||||
ChunterNotificationContentProvider: getChunterNotificationContent,
|
||||
IsDirectMessage,
|
||||
IsThreadMessage,
|
||||
IsMeMentioned,
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Class, Doc, DocumentQuery, FindOptions, FindResult, Hierarchy, Ref } fr
|
||||
import type { Plugin, Resource } from '@hcengineering/platform'
|
||||
import { plugin } from '@hcengineering/platform'
|
||||
import { TriggerFunc } from '@hcengineering/server-core'
|
||||
import { Presenter, TypeMatchFunc } from '@hcengineering/server-notification'
|
||||
import { Presenter, TypeMatchFunc, NotificationContentProvider } from '@hcengineering/server-notification'
|
||||
|
||||
/**
|
||||
* @public
|
||||
@@ -50,6 +50,7 @@ export default plugin(serverChunterId, {
|
||||
IsDirectMessage: '' as TypeMatchFunc,
|
||||
IsChannelMessage: '' as TypeMatchFunc,
|
||||
IsThreadMessage: '' as TypeMatchFunc,
|
||||
IsMeMentioned: '' as TypeMatchFunc
|
||||
IsMeMentioned: '' as TypeMatchFunc,
|
||||
ChunterNotificationContentProvider: '' as Resource<NotificationContentProvider>
|
||||
}
|
||||
})
|
||||
|
||||
@@ -45,16 +45,18 @@ import notification, {
|
||||
ClassCollaborators,
|
||||
Collaborators,
|
||||
DocUpdates,
|
||||
DocUpdateTx,
|
||||
EmailNotification,
|
||||
NotificationProvider,
|
||||
NotificationType
|
||||
} from '@hcengineering/notification'
|
||||
import { getResource } from '@hcengineering/platform'
|
||||
import { IntlString, getResource } from '@hcengineering/platform'
|
||||
import type { TriggerControl } from '@hcengineering/server-core'
|
||||
import serverNotification, {
|
||||
HTMLPresenter,
|
||||
TextPresenter,
|
||||
getEmployee,
|
||||
NotificationPresenter,
|
||||
getPersonAccount,
|
||||
getPersonAccountById
|
||||
} from '@hcengineering/server-notification'
|
||||
@@ -182,6 +184,10 @@ export function getTextPresenter (_class: Ref<Class<Doc>>, hierarchy: Hierarchy)
|
||||
return hierarchy.classHierarchyMixin(_class, serverNotification.mixin.TextPresenter)
|
||||
}
|
||||
|
||||
function getNotificationPresenter (_class: Ref<Class<Doc>>, hierarchy: Hierarchy): NotificationPresenter | undefined {
|
||||
return hierarchy.classHierarchyMixin(_class, serverNotification.mixin.NotificationPresenter)
|
||||
}
|
||||
|
||||
function fillTemplate (template: string, sender: string, doc: string, data: string): string {
|
||||
let res = replaceAll(template, '{sender}', sender)
|
||||
res = replaceAll(res, '{doc}', doc)
|
||||
@@ -462,10 +468,43 @@ async function isShouldNotify (
|
||||
}
|
||||
}
|
||||
|
||||
async function findPersonForAccount (control: TriggerControl, personId: Ref<Person>): Promise<Person | undefined> {
|
||||
const persons = await control.findAll(contact.class.Person, { _id: personId })
|
||||
if (persons !== undefined && persons.length > 0) {
|
||||
return persons[0]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function getFallbackNotificationFullfillment (
|
||||
object: Doc,
|
||||
originTx: TxCUD<Doc>,
|
||||
control: TriggerControl
|
||||
): Promise<Record<string, string | number>> {
|
||||
const intlParams: Record<string, string | number> = {}
|
||||
|
||||
const textPresenter = getTextPresenter(object._class, control.hierarchy)
|
||||
if (textPresenter !== undefined) {
|
||||
const textPresenterFunc = await getResource(textPresenter.presenter)
|
||||
intlParams.title = await textPresenterFunc(object, control)
|
||||
}
|
||||
|
||||
const account = control.modelDb.getObject(originTx.modifiedBy) as PersonAccount
|
||||
if (account !== undefined) {
|
||||
const senderPerson = await findPersonForAccount(control, account.person)
|
||||
if (senderPerson !== undefined) {
|
||||
const senderName = formatName(senderPerson.name)
|
||||
intlParams.senderName = senderName
|
||||
}
|
||||
}
|
||||
|
||||
return intlParams
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function pushNotification (
|
||||
export async function pushNotification (
|
||||
control: TriggerControl,
|
||||
res: Tx[],
|
||||
target: Ref<Account>,
|
||||
@@ -473,7 +512,46 @@ export function pushNotification (
|
||||
originTx: TxCUD<Doc>,
|
||||
docUpdates: DocUpdates[],
|
||||
modifiedBy?: Ref<Account>
|
||||
): void {
|
||||
): Promise<void> {
|
||||
let title: IntlString = notification.string.CommonNotificationTitle
|
||||
let body: IntlString = notification.string.CommonNotificationBody
|
||||
let intlParams: Record<string, string | number> = await getFallbackNotificationFullfillment(object, originTx, control)
|
||||
let intlParamsNotLocalized: Record<string, IntlString> | undefined
|
||||
|
||||
const notificationPresenter = getNotificationPresenter(object._class, control.hierarchy)
|
||||
if (notificationPresenter !== undefined) {
|
||||
const getFuillfillmentParams = await getResource(notificationPresenter.presenter)
|
||||
const updateIntlParams = await getFuillfillmentParams(object, originTx, target, control)
|
||||
title = updateIntlParams.title
|
||||
body = updateIntlParams.body
|
||||
intlParams = {
|
||||
...intlParams,
|
||||
...updateIntlParams.intlParams
|
||||
}
|
||||
if (updateIntlParams.intlParamsNotLocalized != null) {
|
||||
intlParamsNotLocalized = updateIntlParams.intlParamsNotLocalized
|
||||
}
|
||||
}
|
||||
|
||||
const tx: DocUpdateTx = {
|
||||
_id: originTx._id,
|
||||
modifiedOn: originTx.modifiedOn,
|
||||
modifiedBy: modifiedBy ?? originTx.modifiedBy,
|
||||
isNew: true
|
||||
}
|
||||
if (title !== undefined) {
|
||||
tx.title = title
|
||||
}
|
||||
if (body !== undefined) {
|
||||
tx.body = body
|
||||
}
|
||||
if (intlParams !== undefined) {
|
||||
tx.intlParams = intlParams
|
||||
}
|
||||
if (intlParamsNotLocalized !== undefined) {
|
||||
tx.intlParamsNotLocalized = intlParamsNotLocalized
|
||||
}
|
||||
|
||||
const current = docUpdates.find((p) => p.user === target)
|
||||
if (current === undefined) {
|
||||
res.push(
|
||||
@@ -483,27 +561,13 @@ export function pushNotification (
|
||||
attachedToClass: object._class,
|
||||
hidden: false,
|
||||
lastTxTime: originTx.modifiedOn,
|
||||
txes: [
|
||||
{
|
||||
_id: originTx._id,
|
||||
modifiedOn: originTx.modifiedOn,
|
||||
modifiedBy: modifiedBy ?? originTx.modifiedBy,
|
||||
isNew: true
|
||||
}
|
||||
]
|
||||
txes: [tx]
|
||||
})
|
||||
)
|
||||
} else {
|
||||
res.push(
|
||||
control.txFactory.createTxUpdateDoc(current._class, current.space, current._id, {
|
||||
$push: {
|
||||
txes: {
|
||||
_id: originTx._id,
|
||||
modifiedOn: originTx.modifiedOn,
|
||||
modifiedBy: modifiedBy ?? originTx.modifiedBy,
|
||||
isNew: true
|
||||
}
|
||||
}
|
||||
$push: { txes: tx }
|
||||
})
|
||||
)
|
||||
res.push(
|
||||
@@ -528,7 +592,7 @@ async function getNotificationTxes (
|
||||
const res: Tx[] = []
|
||||
const allowed = await isShouldNotify(control, tx, originTx, object, target, isOwn, isSpace)
|
||||
if (allowed.allowed) {
|
||||
pushNotification(control, res, target, object, originTx, docUpdates)
|
||||
await pushNotification(control, res, target, object, originTx, docUpdates)
|
||||
}
|
||||
if (allowed.emails.length === 0) return res
|
||||
const acc = await getPersonAccountById(target, control)
|
||||
@@ -711,7 +775,7 @@ async function updateCollaboratorsMixin (
|
||||
attachedTo: tx.objectId
|
||||
})
|
||||
for (const collab of newCollabs) {
|
||||
pushNotification(control, res, collab, prevDoc, originTx, docUpdates)
|
||||
await pushNotification(control, res, collab, prevDoc, originTx, docUpdates)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
//
|
||||
|
||||
import contact, { Employee, Person, PersonAccount } from '@hcengineering/contact'
|
||||
import { Account, Class, Doc, Mixin, Ref, Tx } from '@hcengineering/core'
|
||||
import { NotificationType } from '@hcengineering/notification'
|
||||
import { Account, Class, Doc, Mixin, Ref, Tx, TxCUD } from '@hcengineering/core'
|
||||
import { NotificationType, NotificationContent } from '@hcengineering/notification'
|
||||
import { Plugin, Resource, plugin } from '@hcengineering/platform'
|
||||
import type { TriggerControl, TriggerFunc } from '@hcengineering/server-core'
|
||||
|
||||
@@ -112,6 +112,23 @@ export interface TypeMatch extends NotificationType {
|
||||
func: TypeMatchFunc
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type NotificationContentProvider = (
|
||||
doc: Doc,
|
||||
tx: TxCUD<Doc>,
|
||||
target: Ref<Account>,
|
||||
control: TriggerControl
|
||||
) => Promise<NotificationContent>
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface NotificationPresenter extends Class<Doc> {
|
||||
presenter: Resource<NotificationContentProvider>
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
@@ -119,7 +136,8 @@ export default plugin(serverNotificationId, {
|
||||
mixin: {
|
||||
HTMLPresenter: '' as Ref<Mixin<HTMLPresenter>>,
|
||||
TextPresenter: '' as Ref<Mixin<TextPresenter>>,
|
||||
TypeMatch: '' as Ref<Mixin<TypeMatch>>
|
||||
TypeMatch: '' as Ref<Mixin<TypeMatch>>,
|
||||
NotificationPresenter: '' as Ref<Mixin<NotificationPresenter>>
|
||||
},
|
||||
trigger: {
|
||||
OnBacklinkCreate: '' as Resource<TriggerFunc>,
|
||||
|
||||
@@ -31,11 +31,13 @@
|
||||
"@hcengineering/server-core": "^0.6.1",
|
||||
"@hcengineering/tracker": "^0.6.11",
|
||||
"@hcengineering/contact": "^0.6.19",
|
||||
"@hcengineering/chunter": "^0.6.10",
|
||||
"@hcengineering/notification": "^0.6.14",
|
||||
"@hcengineering/task": "^0.6.11",
|
||||
"@hcengineering/view": "^0.6.8",
|
||||
"@hcengineering/login": "^0.6.7",
|
||||
"@hcengineering/workbench": "^0.6.8",
|
||||
"@hcengineering/server-task-resources": "^0.6.0"
|
||||
"@hcengineering/server-task-resources": "^0.6.0",
|
||||
"@hcengineering/text": "^0.6.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
//
|
||||
|
||||
import core, {
|
||||
Account,
|
||||
AttachedDoc,
|
||||
concatLink,
|
||||
Doc,
|
||||
@@ -29,11 +30,16 @@ import core, {
|
||||
TxUpdateDoc,
|
||||
WithLookup
|
||||
} from '@hcengineering/core'
|
||||
import { getMetadata } from '@hcengineering/platform'
|
||||
import { getMetadata, IntlString } from '@hcengineering/platform'
|
||||
import { Person, PersonAccount } from '@hcengineering/contact'
|
||||
import serverCore, { TriggerControl } from '@hcengineering/server-core'
|
||||
import tracker, { Component, Issue, IssueParentInfo, TimeSpendReport, trackerId } from '@hcengineering/tracker'
|
||||
import { NotificationContent } from '@hcengineering/notification'
|
||||
import { workbenchId } from '@hcengineering/workbench'
|
||||
|
||||
import chunter, { Comment } from '@hcengineering/chunter'
|
||||
import { stripTags } from '@hcengineering/text'
|
||||
|
||||
async function updateSubIssues (
|
||||
updateTx: TxUpdateDoc<Issue>,
|
||||
control: TriggerControl,
|
||||
@@ -69,6 +75,83 @@ export async function issueTextPresenter (doc: Doc, control: TriggerControl): Pr
|
||||
return issueName
|
||||
}
|
||||
|
||||
function isSamePerson (control: TriggerControl, assignee: Ref<Person>, target: Ref<Account>): boolean {
|
||||
const targetAccount = control.modelDb.getObject(target) as PersonAccount
|
||||
return assignee === targetAccount?.person
|
||||
}
|
||||
|
||||
const NOTIFICATION_BODY_SIZE = 50
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export async function getIssueNotificationContent (
|
||||
doc: Doc,
|
||||
tx: TxCUD<Doc>,
|
||||
target: Ref<Account>,
|
||||
control: TriggerControl
|
||||
): Promise<NotificationContent> {
|
||||
const issue = doc as Issue
|
||||
|
||||
const issueShortName = await issueTextPresenter(doc, control)
|
||||
const issueTitle = `${issueShortName}: ${issue.title}`
|
||||
|
||||
const title = tracker.string.IssueNotificationTitle
|
||||
let body = tracker.string.IssueNotificationBody
|
||||
const intlParams: Record<string, string | number> = {
|
||||
issueTitle
|
||||
}
|
||||
const intlParamsNotLocalized: Record<string, IntlString> = {}
|
||||
|
||||
if (tx._class === core.class.TxCollectionCUD) {
|
||||
const ptx = tx as TxCollectionCUD<Doc, AttachedDoc>
|
||||
|
||||
if (ptx.tx._class === core.class.TxCreateDoc) {
|
||||
if (ptx.tx.objectClass === chunter.class.Comment) {
|
||||
const createTx = ptx.tx as TxCreateDoc<Comment>
|
||||
const message = createTx.attributes.message
|
||||
const plainTextMessage = stripTags(message, NOTIFICATION_BODY_SIZE)
|
||||
intlParams.message = plainTextMessage
|
||||
}
|
||||
} else if (ptx.tx._class === core.class.TxUpdateDoc) {
|
||||
const updateTx = ptx.tx as TxUpdateDoc<Issue>
|
||||
|
||||
if (
|
||||
updateTx.operations.assignee !== null &&
|
||||
updateTx.operations.assignee !== undefined &&
|
||||
isSamePerson(control, updateTx.operations.assignee, target)
|
||||
) {
|
||||
body = tracker.string.IssueAssigneedToYou
|
||||
} else {
|
||||
const attributes = control.hierarchy.getAllAttributes(doc._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 = tracker.string.IssueNotificationChangedProperty
|
||||
intlParams.newValue = (issue as any)[attr.name]?.toString()
|
||||
} else {
|
||||
body = tracker.string.IssueNotificationChanged
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
body,
|
||||
intlParams,
|
||||
intlParamsNotLocalized
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
@@ -157,7 +240,8 @@ export async function OnIssueUpdate (tx: Tx, control: TriggerControl): Promise<T
|
||||
export default async () => ({
|
||||
function: {
|
||||
IssueHTMLPresenter: issueHTMLPresenter,
|
||||
IssueTextPresenter: issueTextPresenter
|
||||
IssueTextPresenter: issueTextPresenter,
|
||||
IssueNotificationContentProvider: getIssueNotificationContent
|
||||
},
|
||||
trigger: {
|
||||
OnIssueUpdate,
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import type { Plugin, Resource } from '@hcengineering/platform'
|
||||
import { plugin } from '@hcengineering/platform'
|
||||
import { TriggerFunc } from '@hcengineering/server-core'
|
||||
import { Presenter } from '@hcengineering/server-notification'
|
||||
import { Presenter, NotificationContentProvider } from '@hcengineering/server-notification'
|
||||
|
||||
/**
|
||||
* @public
|
||||
@@ -29,7 +29,8 @@ export const serverTrackerId = 'server-tracker' as Plugin
|
||||
export default plugin(serverTrackerId, {
|
||||
function: {
|
||||
IssueHTMLPresenter: '' as Resource<Presenter>,
|
||||
IssueTextPresenter: '' as Resource<Presenter>
|
||||
IssueTextPresenter: '' as Resource<Presenter>,
|
||||
IssueNotificationContentProvider: '' as Resource<NotificationContentProvider>
|
||||
},
|
||||
trigger: {
|
||||
OnIssueUpdate: '' as Resource<TriggerFunc>,
|
||||
|
||||
Reference in New Issue
Block a user