diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 5a5de3ac26..877b590bba 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -935,6 +935,9 @@ dependencies: '@types/uuid': specifier: ^8.3.1 version: 8.3.4 + '@types/web-push': + specifier: ~3.6.3 + version: 3.6.3 '@types/ws': specifier: ^8.5.3 version: 8.5.10 @@ -1268,6 +1271,9 @@ dependencies: uuid: specifier: ^8.3.2 version: 8.3.2 + web-push: + specifier: ~3.6.7 + version: 3.6.7 webpack: specifier: ^5.75.0 version: 5.90.3(esbuild@0.20.1)(webpack-cli@5.1.4) @@ -6548,6 +6554,12 @@ packages: resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} dev: false + /@types/web-push@3.6.3: + resolution: {integrity: sha512-v3oT4mMJsHeJ/rraliZ+7TbZtr5bQQuxcgD7C3/1q/zkAj29c8RE0F9lVZVu3hiQe5Z9fYcBreV7TLnfKR+4mg==} + dependencies: + '@types/node': 20.11.19 + dev: false + /@types/webidl-conversions@7.0.3: resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} dev: false @@ -7016,6 +7028,15 @@ packages: - supports-color dev: false + /agent-base@7.1.1: + resolution: {integrity: sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==} + engines: {node: '>= 14'} + dependencies: + debug: 4.3.4 + transitivePeerDependencies: + - supports-color + dev: false + /aggregate-error@3.1.0: resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} engines: {node: '>=8'} @@ -7276,6 +7297,15 @@ packages: is-shared-array-buffer: 1.0.3 dev: false + /asn1.js@5.4.1: + resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + dependencies: + bn.js: 4.12.0 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + safer-buffer: 2.1.2 + dev: false + /assert@2.1.0: resolution: {integrity: sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==} dependencies: @@ -7594,6 +7624,10 @@ packages: readable-stream: 3.6.2 dev: false + /bn.js@4.12.0: + resolution: {integrity: sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==} + dev: false + /body-parser@1.20.2: resolution: {integrity: sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -7714,6 +7748,10 @@ packages: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} dev: false + /buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + dev: false + /buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} dev: false @@ -8869,6 +8907,12 @@ packages: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} dev: false + /ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + dependencies: + safe-buffer: 5.2.1 + dev: false + /ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} dev: false @@ -10784,6 +10828,11 @@ packages: resolve-alpn: 1.2.1 dev: false + /http_ece@1.2.0: + resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==} + engines: {node: '>=16'} + dev: false + /https-proxy-agent@4.0.0: resolution: {integrity: sha512-zoDhWrkR3of1l9QAL8/scJZyLu8j/gBkcwcaQOZh7Gyh/+uJQzGVETdgT30akuwkpL8HTRfssqI3BZuV18teDg==} engines: {node: '>= 6.0.0'} @@ -10804,6 +10853,16 @@ packages: - supports-color dev: false + /https-proxy-agent@7.0.4: + resolution: {integrity: sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==} + engines: {node: '>= 14'} + dependencies: + agent-base: 7.1.1 + debug: 4.3.4 + transitivePeerDependencies: + - supports-color + dev: false + /human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -11981,6 +12040,21 @@ packages: resolution: {integrity: sha512-1IynUYEc/HAwxhi3WDpIpxJbZpMCvvrrmZVqvj9EhpvbH8lls7HhdhiByjL7DkAaWlLIzpC0Xc/VPvy/UxLNjA==} dev: false + /jwa@2.0.0: + resolution: {integrity: sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==} + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + dev: false + + /jws@4.0.0: + resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==} + dependencies: + jwa: 2.0.0 + safe-buffer: 5.2.1 + dev: false + /jwt-simple@0.5.6: resolution: {integrity: sha512-40aUybvhH9t2h71ncA1/1SbtTNCVZHgsTsTgqPUxGWDmUDrXyDf2wMNQKEbdBjbf4AI+fQhbECNTV6lWxQKUzg==} engines: {node: '>= 0.4.0'} @@ -16341,6 +16415,20 @@ packages: '@zxing/text-encoding': 0.9.0 dev: false + /web-push@3.6.7: + resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==} + engines: {node: '>= 16'} + hasBin: true + dependencies: + asn1.js: 5.4.1 + http_ece: 1.2.0 + https-proxy-agent: 7.0.4 + jws: 4.0.0 + minimist: 1.2.8 + transitivePeerDependencies: + - supports-color + dev: false + /webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} dev: false @@ -22283,12 +22371,13 @@ packages: dev: false file:projects/server-notification-resources.tgz(@types/node@20.11.19)(esbuild@0.20.1)(ts-node@10.9.2): - resolution: {integrity: sha512-syXpJRNP0osGwuo3+uTE08SmRqjBPEo4mv6B6hqSvwTXLc7ZbmkEAXbonr+ykj1YQk15u5hS8JjRnWSGStuy7A==, tarball: file:projects/server-notification-resources.tgz} + resolution: {integrity: sha512-9ctaiwEU+M3RZ4Qz0/OuQ1lLh4Os9V8vlpf2FK/cpGOvAt5RUfV0qwiJLaImsLVbS62uz6hbZ1oy7Q3T+qex8Q==, tarball: file:projects/server-notification-resources.tgz} id: file:projects/server-notification-resources.tgz name: '@rush-temp/server-notification-resources' version: 0.0.0 dependencies: '@types/jest': 29.5.12 + '@types/web-push': 3.6.3 '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.56.0)(typescript@5.3.3) '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.3.3) eslint: 8.56.0 @@ -22301,6 +22390,7 @@ packages: prettier-plugin-svelte: 3.2.1(prettier@3.2.5)(svelte@4.2.11) ts-jest: 29.1.2(esbuild@0.20.1)(jest@29.7.0)(typescript@5.3.3) typescript: 5.3.3 + web-push: 3.6.7 transitivePeerDependencies: - '@babel/core' - '@jest/types' diff --git a/dev/docker-compose.yaml b/dev/docker-compose.yaml index c6602865e5..9a4baaeb5b 100644 --- a/dev/docker-compose.yaml +++ b/dev/docker-compose.yaml @@ -156,6 +156,7 @@ services: - MINIO_SECRET_KEY=minioadmin - REKONI_URL=http://rekoni:4004 - FRONT_URL=http://localhost:8087 + - UPLOAD_URL=http://localhost:8087/files # - APM_SERVER_URL=http://apm-server:8200 - SERVER_PROVIDER=ws - ACCOUNTS_URL=http://account:3000 diff --git a/dev/prod/src/platform.ts b/dev/prod/src/platform.ts index 1fc1185116..79c213f4a6 100644 --- a/dev/prod/src/platform.ts +++ b/dev/prod/src/platform.ts @@ -30,7 +30,7 @@ import { imageCropperId } from '@hcengineering/image-cropper' import { inventoryId } from '@hcengineering/inventory' import { leadId } from '@hcengineering/lead' import login, { loginId } from '@hcengineering/login' -import { notificationId } from '@hcengineering/notification' +import notification, { notificationId } from '@hcengineering/notification' import { recruitId } from '@hcengineering/recruit' import rekoni from '@hcengineering/rekoni' import { requestId } from '@hcengineering/request' @@ -96,6 +96,7 @@ interface Config { CALENDAR_URL: string COLLABORATOR_URL: string COLLABORATOR_API_URL: string + PUSH_PUBLIC_KEY: string TITLE?: string LANGUAGES?: string DEFAULT_LANGUAGE?: string @@ -158,6 +159,7 @@ export async function configurePlatform() { setMetadata(telegram.metadata.TelegramURL, config.TELEGRAM_URL ?? 'http://localhost:8086') setMetadata(gmail.metadata.GmailURL, config.GMAIL_URL ?? 'http://localhost:8087') setMetadata(calendar.metadata.CalendarServiceURL, config.CALENDAR_URL ?? 'http://localhost:8095') + setMetadata(notification.metadata.PushPublicKey, config.PUSH_PUBLIC_KEY) setMetadata(login.metadata.OverrideEndpoint, process.env.LOGIN_ENDPOINT) diff --git a/dev/prod/webpack.config.js b/dev/prod/webpack.config.js index 337ade6aee..ad3db50282 100644 --- a/dev/prod/webpack.config.js +++ b/dev/prod/webpack.config.js @@ -36,7 +36,42 @@ const doValidate = !prod || (process.env.DO_VALIDATE === 'true') /** * @type {Configuration} */ -module.exports = { +module.exports = [ + { + mode: dev ? 'development' : mode, + entry: { + serviceWorker: '@hcengineering/notification/src/serviceWorker.ts' + }, + module: { + rules: [ + { + test: /\.ts$/, + exclude: /(node_modules|\.webpack)/, + use: { + loader: 'esbuild-loader', + options: { + target: 'es2021', + keepNames: true, + minify: !prod, + sourcemap: !prod + } + } + } + ] + }, + output: { + path: __dirname + '/dist', + filename: '[name].js', + chunkFilename: '[name].js', + publicPath: '/', + pathinfo: false + }, + resolve: { + extensions: ['.ts', '.js'], + conditionNames: ['svelte', 'browser', 'import'] + } + }, + { entry: { bundle: [ '@hcengineering/theme/styles/global.scss', @@ -278,4 +313,4 @@ module.exports = { } } } -} +}] diff --git a/models/notification/src/index.ts b/models/notification/src/index.ts index b1fdadd194..d6148cc286 100644 --- a/models/notification/src/index.ts +++ b/models/notification/src/index.ts @@ -73,7 +73,9 @@ import { type NotificationSetting, type NotificationStatus, type NotificationTemplate, - type NotificationType + type PushSubscription, + type NotificationType, + type PushSubscriptionKeys } from '@hcengineering/notification' import { getEmbeddedLabel, type Asset, type IntlString, type Resource } from '@hcengineering/platform' import setting from '@hcengineering/setting' @@ -96,6 +98,13 @@ export class TBrowserNotification extends TDoc implements BrowserNotification { status!: NotificationStatus } +@Model(notification.class.PushSubscription, core.class.Doc, DOMAIN_NOTIFICATION) +export class TPushSubscription extends TDoc implements PushSubscription { + user!: Ref + endpoint!: string + keys!: PushSubscriptionKeys +} + @Model(notification.class.BaseNotificationType, core.class.Doc, DOMAIN_MODEL) export class TBaseNotificationType extends TDoc implements BaseNotificationType { generated!: boolean @@ -335,7 +344,8 @@ export function createModel (builder: Builder): void { TActivityNotificationViewlet, TBaseNotificationType, TCommonNotificationType, - TMentionInboxNotification + TMentionInboxNotification, + TPushSubscription ) builder.createDoc( diff --git a/plugins/contact-resources/src/components/Avatar.svelte b/plugins/contact-resources/src/components/Avatar.svelte index 8a04f2c67d..ac8b5fe160 100644 --- a/plugins/contact-resources/src/components/Avatar.svelte +++ b/plugins/contact-resources/src/components/Avatar.svelte @@ -29,7 +29,13 @@ diff --git a/plugins/notification/src/index.ts b/plugins/notification/src/index.ts index a6d68512c9..d45e868d35 100644 --- a/plugins/notification/src/index.ts +++ b/plugins/notification/src/index.ts @@ -30,7 +30,7 @@ import { TxCUD, TxOperations } from '@hcengineering/core' -import type { Asset, IntlString, Plugin, Resource } from '@hcengineering/platform' +import type { Asset, IntlString, Metadata, Plugin, Resource } from '@hcengineering/platform' import { plugin } from '@hcengineering/platform' import { Preference } from '@hcengineering/preference' import { IntegrationType } from '@hcengineering/setting' @@ -51,6 +51,26 @@ export interface BrowserNotification extends Doc { onClickLocation?: Location } +export interface PushData { + tag?: string + title: string + body: string + icon?: string + domain?: string + url?: string +} + +export interface PushSubscriptionKeys { + p256dh: string + auth: string +} + +export interface PushSubscription extends Doc { + user: Ref + endpoint: string + keys: PushSubscriptionKeys +} + /** * @public */ @@ -332,6 +352,7 @@ const notification = plugin(notificationId, { }, class: { BrowserNotification: '' as Ref>, + PushSubscription: '' as Ref>, BaseNotificationType: '' as Ref>, NotificationType: '' as Ref>, CommonNotificationType: '' as Ref>, @@ -353,6 +374,9 @@ const notification = plugin(notificationId, { CollaboratoAddNotification: '' as Ref, MentionCommonNotificationType: '' as Ref }, + metadata: { + PushPublicKey: '' as Metadata + }, providers: { PlatformNotification: '' as Ref, BrowserNotification: '' as Ref, diff --git a/plugins/notification/src/serviceWorker.ts b/plugins/notification/src/serviceWorker.ts new file mode 100644 index 0000000000..3412b3fe47 --- /dev/null +++ b/plugins/notification/src/serviceWorker.ts @@ -0,0 +1,58 @@ +import type { PushData } from './index' + +declare const self: any + +interface PushEvent extends Event { + data: PushMessageData +} + +interface PushMessageData { + json: () => any +} + +self.addEventListener('push', (event: PushEvent) => { + const payload: PushData = event.data.json() + self.registration.showNotification(payload.title, { + body: payload.body, + icon: payload.icon, + tag: payload.tag, + data: { + domain: payload.domain, + url: payload.url + } + }) +}) + +// Listen for notification click event +self.addEventListener('notificationclick', (event: any) => { + const clickedNotification = event.notification + const notificationData = clickedNotification.data + const notificationUrl = notificationData.url + const domain = notificationData.domain + if (notificationUrl !== undefined && domain !== undefined) { + // Check if any client with the same origin is already open + event.waitUntil( + // Check all active clients (browser windows or tabs) + self.clients + .matchAll({ + type: 'window', + includeUncontrolled: true + }) + .then((clientList: any) => { + // Loop through each client + for (const client of clientList) { + // If a client has the same URL origin, focus and navigate to it + if ((client.url as string)?.startsWith(domain)) { + client.postMessage({ + type: 'notification-click', + url: notificationUrl + }) + return client.focus() + } + } + // If no client with the same URL origin is found, open a new window/tab + return self.clients.openWindow(notificationUrl) + }) + ) + } +}) diff --git a/pods/server/src/__start.ts b/pods/server/src/__start.ts index 59da4162ac..295cd2f2e2 100644 --- a/pods/server/src/__start.ts +++ b/pods/server/src/__start.ts @@ -22,49 +22,42 @@ import serverCore, { type StorageConfiguration } from '@hcengineering/server-cor import serverNotification from '@hcengineering/server-notification' import serverToken from '@hcengineering/server-token' import { start } from '.' +import notification from '@hcengineering/notification' -const { - url, - frontUrl, - serverSecret, - sesUrl, - elasticUrl, - elasticIndexName, - accountsUrl, - rekoniUrl, - serverFactory, - serverPort, - enableCompression -} = serverConfigFromEnv() +const config = serverConfigFromEnv() const storageConfig: StorageConfiguration = storageConfigFromEnv() const cursorMaxTime = process.env.SERVER_CURSOR_MAXTIMEMS const lastNameFirst = process.env.LAST_NAME_FIRST === 'true' setMetadata(serverCore.metadata.CursorMaxTimeMS, cursorMaxTime) -setMetadata(serverCore.metadata.FrontUrl, frontUrl) -setMetadata(serverToken.metadata.Secret, serverSecret) -setMetadata(serverNotification.metadata.SesUrl, sesUrl ?? '') +setMetadata(serverCore.metadata.FrontUrl, config.frontUrl) +setMetadata(serverCore.metadata.UploadURL, config.uploadUrl) +setMetadata(serverToken.metadata.Secret, config.serverSecret) +setMetadata(serverNotification.metadata.SesUrl, config.sesUrl ?? '') +setMetadata(notification.metadata.PushPublicKey, config.pushPublicKey) +setMetadata(serverNotification.metadata.PushPrivateKey, config.pushPrivateKey) +setMetadata(serverNotification.metadata.PushSubject, config.pushSubject) setMetadata(contactPlugin.metadata.LastNameFirst, lastNameFirst) -setMetadata(serverCore.metadata.ElasticIndexName, elasticIndexName) +setMetadata(serverCore.metadata.ElasticIndexName, config.elasticIndexName) // eslint-disable-next-line @typescript-eslint/no-floating-promises console.log( - `starting server on ${serverPort} git_version: ${process.env.GIT_REVISION ?? ''} model_version: ${ + `starting server on ${config.serverPort} git_version: ${process.env.GIT_REVISION ?? ''} model_version: ${ process.env.MODEL_VERSION ?? '' }` ) -const shutdown = start(url, { - fullTextUrl: elasticUrl, +const shutdown = start(config.url, { + fullTextUrl: config.elasticUrl, storageConfig, - rekoniUrl, - port: serverPort, - serverFactory, + rekoniUrl: config.rekoniUrl, + port: config.serverPort, + serverFactory: config.serverFactory, indexParallel: 2, indexProcessing: 50, productId: '', - enableCompression, - accountsUrl + enableCompression: config.enableCompression, + accountsUrl: config.accountsUrl }) const close = (): void => { diff --git a/server-plugins/notification-resources/package.json b/server-plugins/notification-resources/package.json index 894fc30109..8715b0f8c6 100644 --- a/server-plugins/notification-resources/package.json +++ b/server-plugins/notification-resources/package.json @@ -29,7 +29,8 @@ "typescript": "^5.3.3", "jest": "^29.7.0", "ts-jest": "^29.1.1", - "@types/jest": "^29.5.5" + "@types/jest": "^29.5.5", + "@types/web-push": "~3.6.3" }, "dependencies": { "@hcengineering/activity": "^0.6.0", @@ -38,8 +39,10 @@ "@hcengineering/server-core": "^0.6.1", "@hcengineering/server-notification": "^0.6.1", "@hcengineering/notification": "^0.6.16", + "@hcengineering/workbench": "^0.6.9", "@hcengineering/chunter": "^0.6.12", "@hcengineering/view": "^0.6.9", - "@hcengineering/contact": "^0.6.20" + "@hcengineering/contact": "^0.6.20", + "web-push": "~3.6.7" } } diff --git a/server-plugins/notification-resources/src/index.ts b/server-plugins/notification-resources/src/index.ts index e954447191..fa6a3ba5c7 100644 --- a/server-plugins/notification-resources/src/index.ts +++ b/server-plugins/notification-resources/src/index.ts @@ -14,8 +14,16 @@ // limitations under the License. // +import activity, { ActivityMessage } from '@hcengineering/activity' import chunter, { ChatMessage } from '@hcengineering/chunter' -import contact, { Employee, formatName, Person, PersonAccount } from '@hcengineering/contact' +import contact, { + Employee, + formatName, + getAvatarProviderId, + getGravatarUrl, + Person, + PersonAccount +} from '@hcengineering/contact' import core, { Account, AnyAttribute, @@ -45,25 +53,25 @@ import core, { import notification, { ActivityInboxNotification, BaseNotificationType, - BrowserNotification, ClassCollaborators, Collaborators, CommonInboxNotification, DocNotifyContext, InboxNotification, notificationId, - NotificationStatus, - NotificationType + NotificationType, + PushData } from '@hcengineering/notification' import { getMetadata, getResource, translate } from '@hcengineering/platform' import type { TriggerControl } from '@hcengineering/server-core' +import serverCore from '@hcengineering/server-core' import serverNotification, { getEmployee, getPersonAccount, getPersonAccountById } from '@hcengineering/server-notification' -import activity, { ActivityMessage } from '@hcengineering/activity' - +import { workbenchId } from '@hcengineering/workbench' +import webpush, { WebPushError } from 'web-push' import { Content, NotifyResult } from './types' import { getHTMLPresenter, @@ -139,6 +147,7 @@ export async function getCommonNotificationTxes ( data, _class, modifiedOn, + sender as Ref, notifyResult.push ) } @@ -369,6 +378,7 @@ export async function pushInboxNotifications ( data: Partial>, _class: Ref>, modifiedOn: Timestamp, + senderId: Ref, shouldPush: boolean, shouldUpdateTimestamp = true ): Promise { @@ -410,10 +420,15 @@ export async function pushInboxNotifications ( const notificationTx = control.txFactory.createTxCreateDoc(_class, space, notificationData) res.push(notificationTx) if (shouldPush) { - const pushTx = await createPushFromInbox(control, targetUser, space, docNotifyContextId, notificationData, _class) - if (pushTx !== undefined) { - res.push(pushTx) - } + await createPushFromInbox( + control, + targetUser, + docNotifyContextId, + notificationData, + _class, + senderId, + notificationTx.objectId + ) } } } @@ -467,11 +482,12 @@ async function commonInboxNotificationToText (doc: Data export async function createPushFromInbox ( control: TriggerControl, targetUser: Ref, - space: Ref, docNotifyContextId: Ref, data: Data, - _class: Ref> -): Promise { + _class: Ref>, + senderId: Ref, + _id: string +): Promise { let title: string = '' let body: string = '' if (control.hierarchy.isDerived(_class, notification.class.ActivityInboxNotification)) { @@ -482,9 +498,14 @@ export async function createPushFromInbox ( if (title === '' || body === '') { return } - return await createPushNotification(control, targetUser, space, title, body, [ - '', - '', + const sender = (await control.modelDb.findAll(contact.class.PersonAccount, { _id: senderId }))[0] + + let senderPerson: Person | undefined + + if (sender !== undefined) { + senderPerson = (await control.findAll(contact.class.Person, { _id: sender.person }))[0] + } + await createPushNotification(control, targetUser, title, body, _id, senderPerson?.avatar, [ notificationId, docNotifyContextId ]) @@ -493,24 +514,62 @@ export async function createPushFromInbox ( export async function createPushNotification ( control: TriggerControl, targetUser: Ref, - space: Ref, title: string, body: string, - onClick?: string[] -): Promise> { - const data: Data = { - user: targetUser, - status: NotificationStatus.New, + _id: string, + senderAvatar?: string | null, + subPath?: string[] +): Promise { + const publicKey = getMetadata(notification.metadata.PushPublicKey) + const privateKey = getMetadata(serverNotification.metadata.PushPrivateKey) + const subject = getMetadata(serverNotification.metadata.PushSubject) ?? 'mailto:hey@huly.io' + if (privateKey === undefined || publicKey === undefined) return + const subscriptions = (await control.queryFind(notification.class.PushSubscription, {})).filter( + (p) => p.user === targetUser + ) + const data: PushData = { title, body } - if (onClick !== undefined) { - data.onClickLocation = { - path: onClick + if (_id !== undefined) { + data.tag = _id + } + const front = getMetadata(serverCore.metadata.FrontUrl) ?? '' + const uploadUrl = getMetadata(serverCore.metadata.UploadURL) ?? '' + const domainPath = `${workbenchId}/${control.workspace.workspaceUrl}` + const domain = concatLink(front, domainPath) + data.domain = domain + if (subPath !== undefined) { + const path = [domainPath, ...subPath].join('/') + const url = concatLink(front, path) + data.url = url + } + if (senderAvatar != null) { + const provider = getAvatarProviderId(senderAvatar) + if (provider === contact.avatarProvider.Image) { + if (senderAvatar.includes('://')) { + data.icon = senderAvatar + } else { + data.icon = concatLink(uploadUrl, `?file=${senderAvatar}`) + } + } else if (provider === contact.avatarProvider.Gravatar) { + data.icon = getGravatarUrl(senderAvatar.split('://')[1], 'medium') + } + } + + webpush.setVapidDetails(subject, publicKey, privateKey) + + for (const subscription of subscriptions) { + try { + await webpush.sendNotification(subscription, JSON.stringify(data)) + } catch (err) { + console.log('Cannot send push notification to', targetUser, err) + if (err instanceof WebPushError && err.body.includes('expired')) { + const tx = control.txFactory.createTxRemoveDoc(subscription._class, subscription.space, subscription._id) + await control.apply([tx], true) + } } } - const res = control.txFactory.createTxCreateDoc(notification.class.BrowserNotification, space, data) - return res } /** @@ -555,6 +614,7 @@ export async function pushActivityInboxNotifications ( data, notification.class.ActivityInboxNotification, activityMessage.modifiedOn, + originTx.modifiedBy as Ref, shouldPush, shouldUpdateTimestamp ) diff --git a/server-plugins/notification/src/index.ts b/server-plugins/notification/src/index.ts index 8dbb94603d..c3467cea69 100644 --- a/server-plugins/notification/src/index.ts +++ b/server-plugins/notification/src/index.ts @@ -134,7 +134,9 @@ export interface NotificationPresenter extends Class { */ export default plugin(serverNotificationId, { metadata: { - SesUrl: '' as Metadata + SesUrl: '' as Metadata, + PushPrivateKey: '' as Metadata, + PushSubject: '' as Metadata }, mixin: { HTMLPresenter: '' as Ref>, diff --git a/server/core/src/plugin.ts b/server/core/src/plugin.ts index 4186f2ed85..f1ed4c3da1 100644 --- a/server/core/src/plugin.ts +++ b/server/core/src/plugin.ts @@ -41,6 +41,7 @@ const serverCore = plugin(serverCoreId, { }, metadata: { FrontUrl: '' as Metadata, + UploadURL: '' as Metadata, CursorMaxTimeMS: '' as Metadata, ElasticIndexName: '' as Metadata } diff --git a/server/server/src/starter.ts b/server/server/src/starter.ts index 1dd3d5a90b..959a1ad47e 100644 --- a/server/server/src/starter.ts +++ b/server/server/src/starter.ts @@ -50,19 +50,25 @@ export function storageConfigFromEnv (): StorageConfiguration { return storageConfig } -export function serverConfigFromEnv (): { +export interface ServerEnv { url: string elasticUrl: string serverSecret: string rekoniUrl: string frontUrl: string + uploadUrl: string sesUrl: string | undefined accountsUrl: string serverPort: number serverFactory: ServerFactory enableCompression: boolean elasticIndexName: string -} { + pushPublicKey: string | undefined + pushPrivateKey: string | undefined + pushSubject: string | undefined +} + +export function serverConfigFromEnv (): ServerEnv { const serverPort = parseInt(process.env.SERVER_PORT ?? '3333') const serverFactory = serverFactories[(process.env.SERVER_PROVIDER as string) ?? 'ws'] ?? serverFactories.ws const enableCompression = (process.env.ENABLE_COMPRESSION ?? 'true') === 'true' @@ -102,6 +108,12 @@ export function serverConfigFromEnv (): { process.exit(1) } + const uploadUrl = process.env.UPLOAD_URL + if (uploadUrl === undefined) { + console.log('Please provide UPLOAD_URL url') + process.exit(1) + } + const sesUrl = process.env.SES_URL const accountsUrl = process.env.ACCOUNTS_URL @@ -109,6 +121,10 @@ export function serverConfigFromEnv (): { console.log('Please provide ACCOUNTS_URL url') process.exit(1) } + + const pushPublicKey = process.env.PUSH_PUBLIC_KEY + const pushPrivateKey = process.env.PUSH_PRIVATE_KEY + const pushSubject = process.env.PUSH_SUBJECT return { url, elasticUrl, @@ -116,11 +132,15 @@ export function serverConfigFromEnv (): { serverSecret, rekoniUrl, frontUrl, + uploadUrl, sesUrl, accountsUrl, serverPort, serverFactory, - enableCompression + enableCompression, + pushPublicKey, + pushPrivateKey, + pushSubject } } diff --git a/tests/docker-compose.yaml b/tests/docker-compose.yaml index c804ad897e..3e19cbe31b 100644 --- a/tests/docker-compose.yaml +++ b/tests/docker-compose.yaml @@ -102,6 +102,7 @@ services: - MINIO_SECRET_KEY=minioadmin - REKONI_URL=http://rekoni:4005 - FRONT_URL=http://localhost:8083 + - UPLOAD_URL=http://localhost:8083/files - ACCOUNTS_URL=http://account:3003 - LAST_NAME_FIRST=true - ELASTIC_INDEX_NAME=local_storage_index