diff --git a/.travis.yml b/.travis.yml index dc89e08f..07944567 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,7 @@ +dist: focal language: node_js node_js: - - v16.15.0 + - v20.15.1 cache: directories: - node_modules @@ -10,6 +11,6 @@ services: - mysql before_install: - cd service - - npm i -g npm@8.5.5 + - npm i -g npm@10.5.0 script: - npm run test:ci:coverage diff --git a/lib/package-lock.json b/lib/package-lock.json index 4a77cd67..0a63730e 100644 --- a/lib/package-lock.json +++ b/lib/package-lock.json @@ -1,12 +1,12 @@ { "name": "@alkemio/notifications-lib", - "version": "0.9.6", + "version": "0.10.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@alkemio/notifications-lib", - "version": "0.9.6", + "version": "0.10.1", "license": "EUPL-1.2", "dependencies": { "@alkemio/client-lib": "^0.32.0" diff --git a/lib/package.json b/lib/package.json index e86f6537..974db3de 100644 --- a/lib/package.json +++ b/lib/package.json @@ -1,6 +1,6 @@ { "name": "@alkemio/notifications-lib", - "version": "0.9.6", + "version": "0.10.1", "description": "Library for interacting with Alkemio notifications service", "author": "Alkemio Foundation", "private": false, @@ -50,6 +50,9 @@ "node": ">=16.15.0", "npm": ">=8.5.5" }, + "volta": { + "node": "20.15.1" + }, "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" diff --git a/lib/src/common/enums/in.app.notification.category.ts b/lib/src/common/enums/in.app.notification.category.ts new file mode 100644 index 00000000..edc023c8 --- /dev/null +++ b/lib/src/common/enums/in.app.notification.category.ts @@ -0,0 +1,5 @@ +export enum InAppNotificationCategory { + SELF = 'self', + MEMBER = 'member', + ADMIN = 'admin', +} diff --git a/lib/src/common/enums/index.ts b/lib/src/common/enums/index.ts new file mode 100644 index 00000000..99a88db8 --- /dev/null +++ b/lib/src/common/enums/index.ts @@ -0,0 +1,2 @@ +export * from "./role.change.type"; +export * from "./in.app.notification.category"; diff --git a/lib/src/dto/collaboration.post.created.event.payload.ts b/lib/src/dto/collaboration.post.created.event.payload.ts index d55f704c..b90c2447 100644 --- a/lib/src/dto/collaboration.post.created.event.payload.ts +++ b/lib/src/dto/collaboration.post.created.event.payload.ts @@ -11,7 +11,6 @@ export interface CollaborationPostCreatedEventPayload extends SpaceBaseEventPayl createdBy: string; displayName: string; nameID: string; - type: string; url: string; }; } diff --git a/lib/src/dto/in-app/in.app.notification.callout.published.payload.ts b/lib/src/dto/in-app/in.app.notification.callout.published.payload.ts new file mode 100644 index 00000000..be07812b --- /dev/null +++ b/lib/src/dto/in-app/in.app.notification.callout.published.payload.ts @@ -0,0 +1,8 @@ +import { InAppNotificationPayloadBase } from "./in.app.notification.payload.base"; +import { NotificationEventType } from '../../notification.event.type'; + +export interface InAppNotificationCalloutPublishedPayload extends InAppNotificationPayloadBase { + type: NotificationEventType.COLLABORATION_CALLOUT_PUBLISHED; + calloutID: string; + spaceID: string; +} diff --git a/lib/src/dto/in-app/in.app.notification.community.new.member.payload.ts b/lib/src/dto/in-app/in.app.notification.community.new.member.payload.ts new file mode 100644 index 00000000..ac6c777f --- /dev/null +++ b/lib/src/dto/in-app/in.app.notification.community.new.member.payload.ts @@ -0,0 +1,10 @@ +import { CommunityContributorType } from '@alkemio/client-lib'; +import { InAppNotificationPayloadBase } from './in.app.notification.payload.base'; +import { NotificationEventType } from '../../notification.event.type'; + +export interface InAppNotificationCommunityNewMemberPayload extends InAppNotificationPayloadBase { + type: NotificationEventType.COMMUNITY_NEW_MEMBER; + contributorType: CommunityContributorType; + newMemberID: string; + spaceID: string; +} diff --git a/lib/src/dto/in-app/in.app.notification.compessed.payload.ts b/lib/src/dto/in-app/in.app.notification.compessed.payload.ts new file mode 100644 index 00000000..0bb0cba6 --- /dev/null +++ b/lib/src/dto/in-app/in.app.notification.compessed.payload.ts @@ -0,0 +1,9 @@ +import { InAppNotificationPayloadBase } from "./in.app.notification.payload.base"; + +/** + * The compressed version of an In-App Notifications. + * It is the same payload but with multiple receivers. + */ +export type CompressedInAppNotificationPayload = Exclude & { + receiverIDs: string[]; +} diff --git a/lib/src/dto/in-app/in.app.notification.contributor.mentioned.payload.ts b/lib/src/dto/in-app/in.app.notification.contributor.mentioned.payload.ts new file mode 100644 index 00000000..7d19ab86 --- /dev/null +++ b/lib/src/dto/in-app/in.app.notification.contributor.mentioned.payload.ts @@ -0,0 +1,13 @@ +import { CommunityContributorType } from "@alkemio/client-lib"; +import { InAppNotificationPayloadBase } from "./in.app.notification.payload.base"; +import { NotificationEventType } from '../../notification.event.type'; + +export interface InAppNotificationContributorMentionedPayload extends InAppNotificationPayloadBase { + type: NotificationEventType.COMMUNICATION_USER_MENTION; + comment: string; // probably will be removed; can be too large; can be replaced with roomID, commentID + contributorType: CommunityContributorType + commentOrigin: { + displayName: string; + url: string; + } +} diff --git a/lib/src/dto/in-app/in.app.notification.payload.base.ts b/lib/src/dto/in-app/in.app.notification.payload.base.ts new file mode 100644 index 00000000..17a5bb8f --- /dev/null +++ b/lib/src/dto/in-app/in.app.notification.payload.base.ts @@ -0,0 +1,11 @@ +import { InAppNotificationCategory } from "../../common/enums"; +import { NotificationEventType } from "../../notification.event.type"; + +export interface InAppNotificationPayloadBase { + receiverID: string; + /** UTC */ + triggeredAt: Date; + type: NotificationEventType; + triggeredByID: string; + category: InAppNotificationCategory; +} diff --git a/lib/src/dto/in-app/in.app.notification.payload.ts b/lib/src/dto/in-app/in.app.notification.payload.ts new file mode 100644 index 00000000..6444f875 --- /dev/null +++ b/lib/src/dto/in-app/in.app.notification.payload.ts @@ -0,0 +1,8 @@ +import { InAppNotificationCalloutPublishedPayload } from "./in.app.notification.callout.published.payload"; +import { InAppNotificationCommunityNewMemberPayload } from "./in.app.notification.community.new.member.payload"; +import { InAppNotificationContributorMentionedPayload } from "./in.app.notification.contributor.mentioned.payload"; + +export type InAppNotificationPayload = + | InAppNotificationCalloutPublishedPayload + | InAppNotificationCommunityNewMemberPayload + | InAppNotificationContributorMentionedPayload; diff --git a/lib/src/dto/in-app/index.ts b/lib/src/dto/in-app/index.ts new file mode 100644 index 00000000..4e616a15 --- /dev/null +++ b/lib/src/dto/in-app/index.ts @@ -0,0 +1,9 @@ +export * from "./in.app.notification.payload.base"; + +export * from "./in.app.notification.payload"; + +export * from "./in.app.notification.compessed.payload"; + +export * from "./in.app.notification.callout.published.payload"; +export * from "./in.app.notification.community.new.member.payload"; +export * from "./in.app.notification.contributor.mentioned.payload"; diff --git a/lib/src/dto/index.ts b/lib/src/dto/index.ts index b1a3afb7..87ee890a 100644 --- a/lib/src/dto/index.ts +++ b/lib/src/dto/index.ts @@ -26,3 +26,4 @@ export * from './platform.forum.discussion.created.event.payload'; export * from './comment.reply.event.payload'; export * from './community.invitation.virtual.contributor.created.event.payload'; export * from './space.created.event.payload'; + diff --git a/lib/src/dto/platform.global.role.change.event.payload.ts b/lib/src/dto/platform.global.role.change.event.payload.ts index d4e2453a..1e386a84 100644 --- a/lib/src/dto/platform.global.role.change.event.payload.ts +++ b/lib/src/dto/platform.global.role.change.event.payload.ts @@ -1,4 +1,4 @@ -import { RoleChangeType } from "@src/common/enums/role.change.type"; +import { RoleChangeType } from "../common/enums"; import { BaseEventPayload } from "./base.event.payload"; import { ContributorPayload } from "./contributor.payload"; @@ -8,4 +8,4 @@ export interface PlatformGlobalRoleChangeEventPayload user: ContributorPayload role: string; actor: ContributorPayload; -}; +} diff --git a/lib/src/index.ts b/lib/src/index.ts index 81cb1179..4aa1aed1 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -1,3 +1,4 @@ export * from './notification.event.type'; export * from './dto'; -export * from './common/enums/role.change.type'; +export * from './dto/in-app'; +export * from './common/enums'; diff --git a/service/notifications.yml b/service/notifications.yml index 98ae9635..b4237583 100644 --- a/service/notifications.yml +++ b/service/notifications.yml @@ -12,6 +12,8 @@ rabbitmq: # RabbitMQ password password: ${RABBITMQ_PASSWORD}:alkemio! + # heartbeat + heartbeat: ${RABBITMQ_HEARTBEAT}:30 ## MONITORING ## # This section defines settings used for DevOps - MONITORING providers, endpoints, logging configuration. @@ -65,6 +67,9 @@ notification_providers: pass: ${EMAIL_SMTP_PASSWORD}:test tls: rejectUnauthorized: ${EMAIL_SMTP_REJECT_UNAUTHORIZED}:false + in_app: + # The name of the queue used for sending in-app notifications + queue: ${IN_APP_QUEUE_NAME}:alkemio-in-app-notifications ## hosting ## # The hosting configuration for the Alkemio Server diff --git a/service/package-lock.json b/service/package-lock.json index 1cce2f7e..7df3dea6 100644 --- a/service/package-lock.json +++ b/service/package-lock.json @@ -1,16 +1,16 @@ { "name": "alkemio-notifications", - "version": "0.19.1", + "version": "0.20.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "alkemio-notifications", - "version": "0.19.1", + "version": "0.20.0", "license": "EUPL-1.2", "dependencies": { "@alkemio/client-lib": "^0.32.0", - "@alkemio/notifications-lib": "^0.9.6", + "@alkemio/notifications-lib": "0.10.1", "@nestjs/common": "^8.0.5", "@nestjs/config": "^1.0.1", "@nestjs/core": "^8.0.5", @@ -61,7 +61,7 @@ "typescript": "^4.3.5" }, "engines": { - "node": ">=16.15.0", + "node": ">=20.15.0", "npm": ">=8.5.5" } }, @@ -186,9 +186,9 @@ } }, "node_modules/@alkemio/notifications-lib": { - "version": "0.9.6", - "resolved": "https://registry.npmjs.org/@alkemio/notifications-lib/-/notifications-lib-0.9.6.tgz", - "integrity": "sha512-xIJ4JOmvo8grfGSeSM7GVfvuXMuIGZoDdcF37hBj/hKguQxzt3fpUMvR7pnWUbC6g5dbXZzoefZblcxreVd3tw==", + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@alkemio/notifications-lib/-/notifications-lib-0.10.1.tgz", + "integrity": "sha512-wHmJCkVtDDWiKiVu5XbjbVpP19vxGLVCgxTE99c22dkE//+BMoGpCcE8EVaELFa+YmBaTSfoXFgYxsTHBdNswA==", "dependencies": { "@alkemio/client-lib": "^0.32.0" }, @@ -14687,9 +14687,9 @@ } }, "@alkemio/notifications-lib": { - "version": "0.9.6", - "resolved": "https://registry.npmjs.org/@alkemio/notifications-lib/-/notifications-lib-0.9.6.tgz", - "integrity": "sha512-xIJ4JOmvo8grfGSeSM7GVfvuXMuIGZoDdcF37hBj/hKguQxzt3fpUMvR7pnWUbC6g5dbXZzoefZblcxreVd3tw==", + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@alkemio/notifications-lib/-/notifications-lib-0.10.1.tgz", + "integrity": "sha512-wHmJCkVtDDWiKiVu5XbjbVpP19vxGLVCgxTE99c22dkE//+BMoGpCcE8EVaELFa+YmBaTSfoXFgYxsTHBdNswA==", "requires": { "@alkemio/client-lib": "^0.32.0" } diff --git a/service/package.json b/service/package.json index 5f65ed35..73f5b26c 100644 --- a/service/package.json +++ b/service/package.json @@ -1,6 +1,6 @@ { "name": "alkemio-notifications", - "version": "0.19.1", + "version": "0.20.0", "description": "Alkemio notifications service", "author": "Alkemio Foundation", "private": false, @@ -18,7 +18,7 @@ "start-nodemon-local": "nodemon -w src --ext ts --exec ts-node -r dotenv/config src/main.ts", "start": "nest start", "start:dev": "nest start --watch", - "start:debug": "nest start --debug --watch", + "start:debug": "nest start --debug=0.0.0.0:9230 --watch", "start:prod": "node dist/main", "start:services": "docker-compose -f quickstart-mailslurper.yml up --build --force-recreate", "lint": "tsc --noEmit && eslint src/**/*.ts{,x}", @@ -36,7 +36,7 @@ }, "dependencies": { "@alkemio/client-lib": "^0.32.0", - "@alkemio/notifications-lib": "^0.9.6", + "@alkemio/notifications-lib": "0.10.1", "@nestjs/common": "^8.0.5", "@nestjs/config": "^1.0.1", "@nestjs/core": "^8.0.5", @@ -95,7 +95,10 @@ } }, "engines": { - "node": ">=16.15.0", + "node": ">=20.15.0", "npm": ">=8.5.5" + }, + "volta": { + "node": "20.15.1" } } diff --git a/service/src/app.controller.ts b/service/src/app.controller.ts index e56b9ffa..61d4364b 100644 --- a/service/src/app.controller.ts +++ b/service/src/app.controller.ts @@ -39,6 +39,11 @@ import { } from '@alkemio/notifications-lib'; import { NotificationService } from './services/domain/notification/notification.service'; import { ALKEMIO_CLIENT_ADAPTER, LogContext } from './common/enums'; +import { + CommunityNewContributorEventSubject, + ContributorMentionedEventSubject, + CalloutPublishedEventSubject, +} from './services/event-subjects'; @Controller() export class AppController { @@ -47,7 +52,10 @@ export class AppController { @Inject(WINSTON_MODULE_NEST_PROVIDER) private readonly logger: LoggerService, @Inject(ALKEMIO_CLIENT_ADAPTER) - private readonly featureFlagProvider: IFeatureFlagProvider + private readonly featureFlagProvider: IFeatureFlagProvider, + private calloutPublishedEventSubject: CalloutPublishedEventSubject, + private contributorMentionedEventSubject: ContributorMentionedEventSubject, + private newContributorEventSubject: CommunityNewContributorEventSubject ) {} @EventPattern(NotificationEventType.COMMUNITY_APPLICATION_CREATED) @@ -56,7 +64,7 @@ export class AppController { @Payload() eventPayload: CommunityApplicationCreatedEventPayload, @Ctx() context: RmqContext ) { - this.sendNotifications( + this.processSent( eventPayload, context, this.notificationService.sendApplicationCreatedNotifications( @@ -72,7 +80,7 @@ export class AppController { @Payload() eventPayload: CommunityInvitationCreatedEventPayload, @Ctx() context: RmqContext ) { - this.sendNotifications( + this.processSent( eventPayload, context, this.notificationService.sendInvitationCreatedNotifications(eventPayload), @@ -86,7 +94,7 @@ export class AppController { eventPayload: CommunityInvitationVirtualContributorCreatedEventPayload, @Ctx() context: RmqContext ) { - this.sendNotifications( + this.processSent( eventPayload, context, this.notificationService.sendVirtualContributorInvitationCreatedNotifications( @@ -102,7 +110,7 @@ export class AppController { @Payload() eventPayload: CommunityPlatformInvitationCreatedEventPayload, @Ctx() context: RmqContext ) { - this.sendNotifications( + this.processSent( eventPayload, context, this.notificationService.sendCommunityPlatformInvitationCreatedNotifications( @@ -118,7 +126,7 @@ export class AppController { @Payload() eventPayload: CommunityNewMemberPayload, @Ctx() context: RmqContext ) { - this.sendNotifications( + this.processSent( eventPayload, context, this.notificationService.sendCommunityNewMemberNotifications( @@ -126,6 +134,8 @@ export class AppController { ), NotificationEventType.COMMUNITY_NEW_MEMBER ); + + this.newContributorEventSubject.notifyAll(eventPayload); } @EventPattern(NotificationEventType.PLATFORM_GLOBAL_ROLE_CHANGE) @@ -134,7 +144,7 @@ export class AppController { @Payload() eventPayload: PlatformGlobalRoleChangeEventPayload, @Ctx() context: RmqContext ) { - this.sendNotifications( + this.processSent( eventPayload, context, this.notificationService.sendGlobalRoleChangeNotification(eventPayload), @@ -148,7 +158,7 @@ export class AppController { @Payload() eventPayload: PlatformUserRegistrationEventPayload, @Ctx() context: RmqContext ) { - this.sendNotifications( + this.processSent( eventPayload, context, this.notificationService.sendUserRegisteredNotification(eventPayload), @@ -162,7 +172,7 @@ export class AppController { @Payload() eventPayload: PlatformUserRemovedEventPayload, @Ctx() context: RmqContext ) { - this.sendNotifications( + this.processSent( eventPayload, context, this.notificationService.sendUserRemovedNotification(eventPayload), @@ -176,7 +186,7 @@ export class AppController { @Payload() eventPayload: CommunicationUpdateEventPayload, @Ctx() context: RmqContext ) { - this.sendNotifications( + this.processSent( eventPayload, context, this.notificationService.sendCommunicationUpdatedNotification( @@ -192,7 +202,7 @@ export class AppController { @Payload() eventPayload: PlatformForumDiscussionCreatedEventPayload, @Ctx() context: RmqContext ) { - this.sendNotifications( + this.processSent( eventPayload, context, this.notificationService.sendPlatformForumDiscussionCreatedNotification( @@ -207,7 +217,7 @@ export class AppController { @Payload() eventPayload: PlatformForumDiscussionCommentEventPayload, @Ctx() context: RmqContext ) { - this.sendNotifications( + this.processSent( eventPayload, context, this.notificationService.sendPlatformForumDiscussionCommentNotification( @@ -222,7 +232,7 @@ export class AppController { @Payload() eventPayload: CommunicationUserMessageEventPayload, @Ctx() context: RmqContext ) { - this.sendNotifications( + this.processSent( eventPayload, context, this.notificationService.sendCommunicationUserMessageNotification( @@ -237,7 +247,7 @@ export class AppController { @Payload() eventPayload: CommunicationOrganizationMessageEventPayload, @Ctx() context: RmqContext ) { - this.sendNotifications( + this.processSent( eventPayload, context, this.notificationService.sendCommunicationOrganizationMessageNotification( @@ -252,7 +262,7 @@ export class AppController { @Payload() eventPayload: CommunicationCommunityLeadsMessageEventPayload, @Ctx() context: RmqContext ) { - this.sendNotifications( + this.processSent( eventPayload, context, this.notificationService.sendCommunicationCommunityLeadsMessageNotification( @@ -267,7 +277,7 @@ export class AppController { @Payload() eventPayload: CommunicationUserMentionEventPayload, @Ctx() context: RmqContext ) { - this.sendNotifications( + this.processSent( eventPayload, context, this.notificationService.sendCommunicationUserMentionNotification( @@ -275,6 +285,8 @@ export class AppController { ), NotificationEventType.COMMUNICATION_USER_MENTION ); + + this.contributorMentionedEventSubject.notifyAll(eventPayload); } @EventPattern(NotificationEventType.COMMUNICATION_ORGANIZATION_MENTION) @@ -282,7 +294,7 @@ export class AppController { @Payload() eventPayload: CommunicationOrganizationMentionEventPayload, @Ctx() context: RmqContext ) { - this.sendNotifications( + this.processSent( eventPayload, context, this.notificationService.sendCommunicationOrganizationMentionNotification( @@ -300,7 +312,7 @@ export class AppController { @Payload() eventPayload: CollaborationWhiteboardCreatedEventPayload, @Ctx() context: RmqContext ) { - this.sendNotifications( + this.processSent( eventPayload, context, this.notificationService.sendWhiteboardCreatedNotification(eventPayload), @@ -313,7 +325,7 @@ export class AppController { @Payload() eventPayload: CollaborationPostCreatedEventPayload, @Ctx() context: RmqContext ) { - this.sendNotifications( + this.processSent( eventPayload, context, this.notificationService.sendPostCreatedNotification(eventPayload), @@ -326,7 +338,7 @@ export class AppController { @Payload() eventPayload: CollaborationPostCommentEventPayload, @Ctx() context: RmqContext ) { - this.sendNotifications( + this.processSent( eventPayload, context, this.notificationService.sendPostCommentCreatedNotification(eventPayload), @@ -342,7 +354,7 @@ export class AppController { @Payload() eventPayload: CollaborationDiscussionCommentEventPayload, @Ctx() context: RmqContext ) { - this.sendNotifications( + this.processSent( eventPayload, context, this.notificationService.sendDiscussionCommentCreatedNotification( @@ -360,12 +372,14 @@ export class AppController { @Payload() eventPayload: CollaborationCalloutPublishedEventPayload, @Ctx() context: RmqContext ) { - this.sendNotifications( + this.processSent( eventPayload, context, this.notificationService.sendCalloutPublishedNotification(eventPayload), NotificationEventType.COLLABORATION_CALLOUT_PUBLISHED ); + + this.calloutPublishedEventSubject.notifyAll(eventPayload); } @EventPattern(NotificationEventType.COMMENT_REPLY, Transport.RMQ) @@ -373,7 +387,7 @@ export class AppController { @Payload() eventPayload: CommentReplyEventPayload, @Ctx() context: RmqContext ) { - this.sendNotifications( + this.processSent( eventPayload, context, this.notificationService.sendCommentReplyNotification(eventPayload), @@ -387,15 +401,17 @@ export class AppController { eventPayload: SpaceCreatedEventPayload, @Ctx() context: RmqContext ) { - this.sendNotifications( + this.processSent( eventPayload, context, - this.notificationService.sendSpaceCreatedNotification(eventPayload), + this.notificationService.buildAndSendSpaceCreatedNotification( + eventPayload + ), NotificationEventType.SPACE_CREATED ); } - private async sendNotifications( + private async processSent( @Payload() eventPayload: BaseEventPayload, @Ctx() context: RmqContext, sentNotifications: Promise[]>, diff --git a/service/src/app.module.ts b/service/src/app.module.ts index f07e920b..f2639705 100644 --- a/service/src/app.module.ts +++ b/service/src/app.module.ts @@ -43,6 +43,18 @@ import { PlatformGlobalRoleChangeNotificationBuilder } from './services/domain/b import { CommunityInvitationVirtualContributorCreatedNotificationBuilder } from './services/domain/builders/community-invitation-virtual-contributor-created/community.invitation.virtual.contributor.created.notification.builder'; import { HealthController } from './health.controller'; import { SpaceCreatedNotificationBuilder } from './services/domain/builders/space-created/space.created.notification.builder'; +import { + CalloutPublishedInAppNotificationBuilder, + CommunityNewContributorInAppNotificationBuilder, + ContributorMentionedInAppNotificationBuilder, +} from './services/builders/in-app'; +import { InAppDispatcher } from './services/dispatchers'; +import { InAppBuilderUtil } from './services/builders/utils'; +import { + CommunityNewContributorEventSubject, + ContributorMentionedEventSubject, + CalloutPublishedEventSubject, +} from './services/event-subjects'; @Module({ imports: [ @@ -91,6 +103,19 @@ import { SpaceCreatedNotificationBuilder } from './services/domain/builders/spac NotificationService, CommunityInvitationVirtualContributorCreatedNotificationBuilder, SpaceCreatedNotificationBuilder, + // + InAppDispatcher, + // + InAppBuilderUtil, + // + CalloutPublishedEventSubject, + CalloutPublishedInAppNotificationBuilder, + // + CommunityNewContributorEventSubject, + CommunityNewContributorInAppNotificationBuilder, + // + ContributorMentionedEventSubject, + ContributorMentionedInAppNotificationBuilder, ], controllers: [AppController, HealthController], }) diff --git a/service/src/common/enums/logging.context.ts b/service/src/common/enums/logging.context.ts index c5cbb046..a8a7a7d9 100644 --- a/service/src/common/enums/logging.context.ts +++ b/service/src/common/enums/logging.context.ts @@ -1,6 +1,6 @@ export enum LogContext { - COMMUNICATION = 'communication', - LIFECYCLE = 'lifecycle', UNSPECIFIED = 'not_specified', NOTIFICATIONS = 'notifications', + IN_APP_DISPATCHER = 'in-app-dispatcher', + IN_APP_BUILDER = 'in-app-builder', } diff --git a/service/src/main.ts b/service/src/main.ts index 905ab6d4..306157ee 100644 --- a/service/src/main.ts +++ b/service/src/main.ts @@ -5,6 +5,7 @@ import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; import { AppModule } from './app.module'; import { ConfigurationTypes } from './common/enums'; import './config/aliases'; +import { INestApplication } from '@nestjs/common'; const bootstrap = async () => { const app = await NestFactory.create(AppModule); @@ -19,16 +20,29 @@ const bootstrap = async () => { logger.verbose(`Server is listening on port ${port}`); }); - const amqpEndpoint = `amqp://${connectionOptions.user}:${connectionOptions.password}@${connectionOptions.host}:${connectionOptions.port}?heartbeat=30`; + const heartbeat = process.env.NODE_ENV === 'production' ? 30 : 120; + const amqpEndpoint = `amqp://${connectionOptions.user}:${connectionOptions.password}@${connectionOptions.host}:${connectionOptions.port}?heartbeat=${heartbeat}`; + try { + connectMicroservice(app, amqpEndpoint, 'alkemio-notifications'); + await app.startAllMicroservices(); + } catch (e: any) { + logger.error(`Failed to start microservices: ${e.message}`); + process.exit(1); + } +}; + +const connectMicroservice = ( + app: INestApplication, + amqpEndpoint: string, + queue: string +) => { app.connectMicroservice({ transport: Transport.RMQ, options: { urls: [amqpEndpoint], - queue: 'alkemio-notifications', - queueOptions: { - durable: true, - }, + queue, + queueOptions: { durable: true }, socketOptions: { reconnectTimeInSeconds: 5, heartbeatIntervalInSeconds: 30, @@ -37,7 +51,6 @@ const bootstrap = async () => { noAck: false, }, }); - await app.startAllMicroservices(); }; bootstrap(); diff --git a/service/src/services/builders/email/.gitkeep b/service/src/services/builders/email/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/service/src/services/builders/in-app/callout.published.in.app.notification.builder.ts b/service/src/services/builders/in-app/callout.published.in.app.notification.builder.ts new file mode 100644 index 00000000..306f846e --- /dev/null +++ b/service/src/services/builders/in-app/callout.published.in.app.notification.builder.ts @@ -0,0 +1,79 @@ +import { + CollaborationCalloutPublishedEventPayload, + CompressedInAppNotificationPayload, + InAppNotificationCalloutPublishedPayload, + InAppNotificationCategory, + NotificationEventType, +} from '@alkemio/notifications-lib'; +import { AuthorizationCredential } from '@alkemio/client-lib/dist/generated/graphql'; +import { UserPreferenceType } from '@alkemio/client-lib'; +import { Inject, Injectable, LoggerService } from '@nestjs/common'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; +import { NotificationBuilder } from '../notification.builder'; +import { InAppDispatcher } from '../../dispatchers'; +import { InAppBuilderUtil } from '../utils'; +import { InAppReceiverConfig } from '../in.app.receiver.config'; + +@Injectable() +export class CalloutPublishedInAppNotificationBuilder + implements NotificationBuilder +{ + constructor( + private readonly inAppDispatcher: InAppDispatcher, + private readonly util: InAppBuilderUtil, + @Inject(WINSTON_MODULE_NEST_PROVIDER) private logger: LoggerService + ) {} + public async buildAndSend( + event: CollaborationCalloutPublishedEventPayload + ): Promise { + // the config can be defined per notification type in a centralized place + // and retrieved using the config service + const roleConfig: InAppReceiverConfig[] = [ + { + category: InAppNotificationCategory.MEMBER, + credential: { + type: AuthorizationCredential.SpaceMember, + resourceID: event.space.id, + }, + preferenceType: UserPreferenceType.NotificationCalloutPublished, + }, + ]; + const notifications = await this.util.genericBuild( + roleConfig, + event, + calloutPublishedBuilder + ); + + try { + this.inAppDispatcher.dispatch(notifications); + } catch (e: any) { + this.logger.error( + `${CalloutPublishedInAppNotificationBuilder.name} failed to dispatch in-app notification`, + e?.stack + ); + } + } +} + +const calloutPublishedBuilder = ( + category: InAppNotificationCategory, + receiverIDs: string[], + event: CollaborationCalloutPublishedEventPayload +): CompressedInAppNotificationPayload => { + const { + callout: { id: calloutID }, + space: { id: spaceID }, + triggeredBy: triggeredByID, + } = event; + + return { + type: NotificationEventType.COLLABORATION_CALLOUT_PUBLISHED, + triggeredAt: new Date(), + receiverIDs, + category, + calloutID, + spaceID, + triggeredByID, + receiverID: '', + }; +}; diff --git a/service/src/services/builders/in-app/community.new.contributor.in.app.notification.builder.ts b/service/src/services/builders/in-app/community.new.contributor.in.app.notification.builder.ts new file mode 100644 index 00000000..c4590551 --- /dev/null +++ b/service/src/services/builders/in-app/community.new.contributor.in.app.notification.builder.ts @@ -0,0 +1,89 @@ +import { + CommunityNewMemberPayload, + CompressedInAppNotificationPayload, + InAppNotificationCategory, + InAppNotificationCommunityNewMemberPayload, + NotificationEventType, +} from '@alkemio/notifications-lib'; +import { AuthorizationCredential } from '@alkemio/client-lib/dist/generated/graphql'; +import { + CommunityContributorType, + UserPreferenceType, +} from '@alkemio/client-lib'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; +import { Inject, Injectable, LoggerService } from '@nestjs/common'; +import { NotificationBuilder } from '../notification.builder'; +import { InAppDispatcher } from '../../dispatchers'; +import { InAppBuilderUtil } from '../utils'; +import { InAppReceiverConfig } from '../in.app.receiver.config'; + +@Injectable() +export class CommunityNewContributorInAppNotificationBuilder + implements NotificationBuilder +{ + constructor( + private readonly inAppDispatcher: InAppDispatcher, + private readonly util: InAppBuilderUtil, + @Inject(WINSTON_MODULE_NEST_PROVIDER) private logger: LoggerService + ) {} + public async buildAndSend(event: CommunityNewMemberPayload): Promise { + // the config can be defined per notification type in a centralized place + // and retrieved using the config service + const roleConfig: InAppReceiverConfig[] = [ + { + category: InAppNotificationCategory.ADMIN, + preferenceType: UserPreferenceType.NotificationCommunityNewMemberAdmin, + credential: { + type: AuthorizationCredential.SpaceAdmin, + resourceID: event.space.id, + }, + }, + { + category: InAppNotificationCategory.MEMBER, + preferenceType: UserPreferenceType.NotificationCommunityNewMember, + credential: { + type: AuthorizationCredential.UserSelfManagement, + resourceID: event.contributor.id, + }, + }, + ]; + const notifications = await this.util.genericBuild( + roleConfig, + event, + newMemberBuilder + ); + + try { + this.inAppDispatcher.dispatch(notifications); + } catch (e: any) { + this.logger.error( + `${CommunityNewContributorInAppNotificationBuilder.name} failed to dispatch in-app notification`, + e?.stack + ); + } + } +} + +const newMemberBuilder = ( + category: InAppNotificationCategory, + receiverIDs: string[], + event: CommunityNewMemberPayload +): CompressedInAppNotificationPayload => { + const { + space: { id: spaceID }, + triggeredBy: triggeredByID, + contributor: { id: newMemberID, type: contributorType }, + } = event; + + return { + type: NotificationEventType.COMMUNITY_NEW_MEMBER, + triggeredAt: new Date(), + receiverIDs, + category, + spaceID, + triggeredByID, + newMemberID, + contributorType: contributorType as CommunityContributorType, + receiverID: '', + }; +}; diff --git a/service/src/services/builders/in-app/contributor.mentioned.in.app.notification.builder.ts b/service/src/services/builders/in-app/contributor.mentioned.in.app.notification.builder.ts new file mode 100644 index 00000000..96bffbc4 --- /dev/null +++ b/service/src/services/builders/in-app/contributor.mentioned.in.app.notification.builder.ts @@ -0,0 +1,87 @@ +import { + CommunicationUserMentionEventPayload, + CompressedInAppNotificationPayload, + InAppNotificationCategory, + InAppNotificationContributorMentionedPayload, + NotificationEventType, +} from '@alkemio/notifications-lib'; +import { AuthorizationCredential } from '@alkemio/client-lib/dist/generated/graphql'; +import { + CommunityContributorType, + UserPreferenceType, +} from '@alkemio/client-lib'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; +import { Inject, Injectable, LoggerService } from '@nestjs/common'; +import { NotificationBuilder } from '../notification.builder'; +import { InAppDispatcher } from '../../dispatchers'; +import { InAppBuilderUtil } from '../utils'; +import { InAppReceiverConfig } from '../in.app.receiver.config'; + +@Injectable() +export class ContributorMentionedInAppNotificationBuilder + implements NotificationBuilder +{ + constructor( + private readonly inAppDispatcher: InAppDispatcher, + private readonly util: InAppBuilderUtil, + @Inject(WINSTON_MODULE_NEST_PROVIDER) private logger: LoggerService + ) {} + public async buildAndSend( + event: CommunicationUserMentionEventPayload + ): Promise { + // the config can be defined per notification type in a centralized place + // and retrieved using the config service + const roleConfig: InAppReceiverConfig[] = [ + { + category: InAppNotificationCategory.SELF, + preferenceType: UserPreferenceType.NotificationCommunicationMention, + credential: { + type: AuthorizationCredential.UserSelfManagement, + resourceID: event.mentionedUser.id, + }, + }, + ]; + const notifications = await this.util.genericBuild( + roleConfig, + event, + contributorMentionBuilder + ); + + try { + this.inAppDispatcher.dispatch(notifications); + } catch (e: any) { + this.logger.error( + `${ContributorMentionedInAppNotificationBuilder.name} failed to dispatch in-app notification`, + e?.stack + ); + } + } +} + +const contributorMentionBuilder = ( + category: InAppNotificationCategory, + receiverIDs: string[], + event: CommunicationUserMentionEventPayload +): CompressedInAppNotificationPayload => { + const { + triggeredBy: triggeredByID, + comment, + commentOrigin, + mentionedUser: { type: contributorType }, + } = event; + + return { + type: NotificationEventType.COMMUNICATION_USER_MENTION, + triggeredAt: new Date(), + receiverIDs, + category, + triggeredByID, + comment, + commentOrigin: { + url: commentOrigin.url, + displayName: commentOrigin.displayName, + }, + contributorType: contributorType as CommunityContributorType, + receiverID: '', + }; +}; diff --git a/service/src/services/builders/in-app/index.ts b/service/src/services/builders/in-app/index.ts new file mode 100644 index 00000000..f84800f5 --- /dev/null +++ b/service/src/services/builders/in-app/index.ts @@ -0,0 +1,3 @@ +export * from './callout.published.in.app.notification.builder'; +export * from './community.new.contributor.in.app.notification.builder'; +export * from './contributor.mentioned.in.app.notification.builder'; diff --git a/service/src/services/builders/in.app.payload.builder.fn.ts b/service/src/services/builders/in.app.payload.builder.fn.ts new file mode 100644 index 00000000..43093774 --- /dev/null +++ b/service/src/services/builders/in.app.payload.builder.fn.ts @@ -0,0 +1,14 @@ +import { + CompressedInAppNotificationPayload, + InAppNotificationCategory, + InAppNotificationPayloadBase, +} from '@alkemio/notifications-lib'; + +export type InAppPayloadBuilderFn< + TEvent, + TPayload extends InAppNotificationPayloadBase +> = ( + category: InAppNotificationCategory, + receiverIDs: string[], + event: TEvent +) => CompressedInAppNotificationPayload; diff --git a/service/src/services/builders/in.app.receiver.config.ts b/service/src/services/builders/in.app.receiver.config.ts new file mode 100644 index 00000000..4bb6f5db --- /dev/null +++ b/service/src/services/builders/in.app.receiver.config.ts @@ -0,0 +1,12 @@ +import { InAppNotificationCategory } from '@alkemio/notifications-lib'; +import { AuthorizationCredential } from '@alkemio/client-lib/dist/generated/graphql'; +import { UserPreferenceType } from '@alkemio/client-lib'; + +export type InAppReceiverConfig = { + category: InAppNotificationCategory; + credential: { + type: AuthorizationCredential; + resourceID?: string; + }; + preferenceType?: UserPreferenceType; +}; diff --git a/service/src/services/builders/index.ts b/service/src/services/builders/index.ts new file mode 100644 index 00000000..af3f80fc --- /dev/null +++ b/service/src/services/builders/index.ts @@ -0,0 +1 @@ +export * from './notification.builder'; diff --git a/service/src/services/builders/notification.builder.ts b/service/src/services/builders/notification.builder.ts new file mode 100644 index 00000000..93e2badc --- /dev/null +++ b/service/src/services/builders/notification.builder.ts @@ -0,0 +1,5 @@ +import { BaseEventPayload } from '@alkemio/notifications-lib'; + +export interface NotificationBuilder { + buildAndSend(event: BaseEventPayload): Promise; +} diff --git a/service/src/services/builders/utils/in.app.builder.util.ts b/service/src/services/builders/utils/in.app.builder.util.ts new file mode 100644 index 00000000..5538bd7c --- /dev/null +++ b/service/src/services/builders/utils/in.app.builder.util.ts @@ -0,0 +1,108 @@ +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; +import { Inject, Injectable, LoggerService } from '@nestjs/common'; +import { ALKEMIO_CLIENT_ADAPTER, LogContext } from '@common/enums'; +import { AlkemioClientAdapter } from '@src/services'; +import { + BaseEventPayload, + CompressedInAppNotificationPayload, + InAppNotificationPayloadBase, +} from '@alkemio/notifications-lib'; +import { + AuthorizationCredential, + UserPreferenceType, +} from '@alkemio/client-lib'; +import { InAppReceiverConfig } from '../in.app.receiver.config'; +import { InAppPayloadBuilderFn } from '../in.app.payload.builder.fn'; + +@Injectable() +export class InAppBuilderUtil { + constructor( + @Inject(ALKEMIO_CLIENT_ADAPTER) + private alkemioAdapter: AlkemioClientAdapter, + @Inject(WINSTON_MODULE_NEST_PROVIDER) private logger: LoggerService + ) {} + + public async genericBuild< + TEvent extends BaseEventPayload, + TPayload extends InAppNotificationPayloadBase + >( + config: InAppReceiverConfig[], + event: TEvent, + payloadBuilder: InAppPayloadBuilderFn + ): Promise[]> { + const notifications: CompressedInAppNotificationPayload[] = []; + for (const { category, credential, preferenceType } of config) { + // get receivers for this role + const receiversByCategory = await this.getReceivers({ + credential, + preferenceType, + }); + // do not continue if there are no receivers + if (receiversByCategory.length === 0) { + this.logger.verbose?.( + `Skipping in-app notification for receivers with preference '${preferenceType}' and category of '${category}' because there are not any`, + LogContext.IN_APP_BUILDER + ); + continue; + } + // build notifications per roleConfig entry + const compressedNotification = payloadBuilder( + category, + receiversByCategory, + event + ); + + if (this.logger.verbose) { + this.logger.verbose( + `Built in-app notification of type '${compressedNotification.type}' and category '${category}' for ${compressedNotification.receiverIDs.length} receivers`, + LogContext.IN_APP_BUILDER + ); + } + + notifications.push(compressedNotification); + } + + return notifications; + } + + /** + * Gets the users for a given credential and filters them by preference + * @param options + * @private + * @returns Fully qualified receivers in a list of user IDs + */ + private async getReceivers(options: { + credential: { + type: AuthorizationCredential; + resourceID?: string; + }; + preferenceType?: UserPreferenceType; + }): Promise { + const { credential, preferenceType } = options; + const recipients = + await this.alkemioAdapter.getUniqueUsersMatchingCredentialCriteria([ + credential, + ]); + + if (!preferenceType) { + return extractId(recipients); + } + + const recipientsWithActivePreference = recipients.filter(r => { + const targetPreference = r.preferences?.find( + p => p.definition.type === preferenceType + ); + + if (!targetPreference) { + return false; + } + // later to take into account the preference value type and test against the proper value + return targetPreference?.value === 'true'; + }); + + return extractId(recipientsWithActivePreference); + } +} + +const extractId = (data: T[]): string[] => + data.map(({ id }) => id); diff --git a/service/src/services/builders/utils/index.ts b/service/src/services/builders/utils/index.ts new file mode 100644 index 00000000..a3340ac2 --- /dev/null +++ b/service/src/services/builders/utils/index.ts @@ -0,0 +1 @@ +export * from './in.app.builder.util'; diff --git a/service/src/services/dispatchers/dispatcher.ts b/service/src/services/dispatchers/dispatcher.ts new file mode 100644 index 00000000..e9c92366 --- /dev/null +++ b/service/src/services/dispatchers/dispatcher.ts @@ -0,0 +1,3 @@ +export interface Dispatcher { + dispatch(data: any): void; +} diff --git a/service/src/services/dispatchers/email/.gitkeep b/service/src/services/dispatchers/email/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/service/src/services/dispatchers/in-app/in.app.client.proxy.factory.ts b/service/src/services/dispatchers/in-app/in.app.client.proxy.factory.ts new file mode 100644 index 00000000..418a9071 --- /dev/null +++ b/service/src/services/dispatchers/in-app/in.app.client.proxy.factory.ts @@ -0,0 +1,48 @@ +import { + ClientProxy, + ClientProxyFactory, + RmqOptions, + Transport, +} from '@nestjs/microservices'; +import { LoggerService } from '@nestjs/common'; + +export const inAppClientProxyFactory = ( + config: { + user: string; + password: string; + host: string; + port: number; + heartbeat: number; + queue: string; + }, + logger: LoggerService +): ClientProxy | undefined => { + const { host, port, user, password, heartbeat: _heartbeat, queue } = config; + const heartbeat = + process.env.NODE_ENV === 'production' ? _heartbeat : _heartbeat * 3; + logger.verbose?.({ ...config, heartbeat, password: undefined }); + try { + const options: RmqOptions = { + transport: Transport.RMQ, + options: { + urls: [ + { + protocol: 'amqp', + hostname: host, + username: user, + password, + port, + heartbeat, + }, + ], + queue, + queueOptions: { durable: true }, + noAck: true, + }, + }; + return ClientProxyFactory.create(options); + } catch (err) { + logger.error(`Could not connect to RabbitMQ: ${err}`); + return undefined; + } +}; diff --git a/service/src/services/dispatchers/in-app/in.app.dispatcher.ts b/service/src/services/dispatchers/in-app/in.app.dispatcher.ts new file mode 100644 index 00000000..9f59adc0 --- /dev/null +++ b/service/src/services/dispatchers/in-app/in.app.dispatcher.ts @@ -0,0 +1,107 @@ +import { + CompressedInAppNotificationPayload, + InAppNotificationPayload, +} from '@alkemio/notifications-lib'; +import { Inject, Injectable, LoggerService } from '@nestjs/common'; +import { ClientProxy } from '@nestjs/microservices'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; +import { ConfigService } from '@nestjs/config'; +import { RMQConnectionError } from '@src/types'; +import { Dispatcher } from '../dispatcher'; +import { inAppClientProxyFactory } from './in.app.client.proxy.factory'; +import { LogContext } from '@common/enums'; + +@Injectable() +export class InAppDispatcher implements Dispatcher { + private readonly client: ClientProxy | undefined; + + constructor( + @Inject(WINSTON_MODULE_NEST_PROVIDER) private logger: LoggerService, + private readonly configService: ConfigService + ) { + const rabbitMqOptions = this.configService.get('rabbitmq.connection'); + const queue = this.configService.get('notification_providers.in_app.queue'); + + this.client = inAppClientProxyFactory( + { + ...rabbitMqOptions, + queue, + }, + this.logger + ); + + if (!this.client) { + this.logger.error( + `${InAppDispatcher.name} not initialized`, + undefined, + LogContext.IN_APP_DISPATCHER + ); + return; + } + // don't block the constructor + this.client + .connect() + .then(() => { + this.logger.verbose?.( + 'Client proxy successfully connected to RabbitMQ', + LogContext.IN_APP_DISPATCHER + ); + }) + .catch((error: RMQConnectionError | undefined) => + this.logger.error( + error?.err, + error?.err.stack, + LogContext.IN_APP_DISPATCHER + ) + ); + } + + dispatch( + data: CompressedInAppNotificationPayload[] + ): void { + if (data.length === 0) { + this.logger.verbose?.( + 'Zero in-app compressed notification payload were given for dispatch', + LogContext.IN_APP_DISPATCHER + ); + return; + } + + if (this.logger.verbose) { + const receiversCount = data.reduce( + (acc, value) => acc + value.receiverIDs.length, + 0 + ); + this.logger.verbose( + `Dispatching ${data.length} in-app compressed notification payloads for a total of ${receiversCount} receivers`, + LogContext.IN_APP_DISPATCHER + ); + } + + try { + this.sendWithoutResponse(data); + } catch (e: any) { + throw e; + } + } + + /** + * Sends a message to the queue without waiting for a response. + * Each consumer needs to manually handle failures, returning the proper type. + * @param data + */ + private sendWithoutResponse = (data: TInput): void | never => { + const pattern = 'in-app-notification-incoming'; + if (!this.client) { + throw new Error('Connection was not established. Sending failed.'); + } + + this.logger.debug?.({ + method: 'sendWithoutResponse', + pattern, + data, + }); + + this.client.emit(pattern, data); + }; +} diff --git a/service/src/services/dispatchers/in-app/index.ts b/service/src/services/dispatchers/in-app/index.ts new file mode 100644 index 00000000..3a9959ea --- /dev/null +++ b/service/src/services/dispatchers/in-app/index.ts @@ -0,0 +1 @@ +export * from './in.app.dispatcher'; diff --git a/service/src/services/dispatchers/index.ts b/service/src/services/dispatchers/index.ts new file mode 100644 index 00000000..0b8e646d --- /dev/null +++ b/service/src/services/dispatchers/index.ts @@ -0,0 +1,2 @@ +export * from './dispatcher'; +export * from './in-app'; diff --git a/service/src/services/domain/builders/collaboration-callout-published/collaboration.callout.published.notification.builder.ts b/service/src/services/domain/builders/collaboration-callout-published/collaboration.callout.published.notification.builder.ts index 0a1e89bd..709b9879 100644 --- a/service/src/services/domain/builders/collaboration-callout-published/collaboration.callout.published.notification.builder.ts +++ b/service/src/services/domain/builders/collaboration-callout-published/collaboration.callout.published.notification.builder.ts @@ -9,7 +9,6 @@ import { CollaborationCalloutPublishedEventPayload } from '@alkemio/notification import { CollaborationCalloutPublishedEmailPayload } from '@common/email-template-payload'; import { NotificationEventType } from '@alkemio/notifications-lib'; import { AlkemioUrlGenerator } from '@src/services/application/alkemio-url-generator/alkemio.url.generator'; -import { ConfigService } from '@nestjs/config'; @Injectable() export class CollaborationCalloutPublishedNotificationBuilder @@ -20,8 +19,7 @@ export class CollaborationCalloutPublishedNotificationBuilder CollaborationCalloutPublishedEventPayload, CollaborationCalloutPublishedEmailPayload >, - private readonly alkemioUrlGenerator: AlkemioUrlGenerator, - private readonly configService: ConfigService + private readonly alkemioUrlGenerator: AlkemioUrlGenerator ) {} build( payload: CollaborationCalloutPublishedEventPayload diff --git a/service/src/services/domain/notification/notification.service.ts b/service/src/services/domain/notification/notification.service.ts index 3aefe94f..84480dd3 100644 --- a/service/src/services/domain/notification/notification.service.ts +++ b/service/src/services/domain/notification/notification.service.ts @@ -99,37 +99,10 @@ export class NotificationService { private spaceCreatedNotificationBuilder: SpaceCreatedNotificationBuilder ) {} - async sendNotifications( - payload: BaseEventPayload, - notificationBuilder: INotificationBuilder - ): Promise[]> { - const notificationsEnabled = - await this.alkemioClientAdapter.areNotificationsEnabled(); - if (!notificationsEnabled) { - this.logger.verbose?.( - 'Notification disabled. No notifications are going to be built.', - LogContext.NOTIFICATIONS - ); - - return []; - } - - const notifications = await notificationBuilder.build(payload); - - try { - return Promise.allSettled( - notifications.map(x => this.sendNotification(x)) - ); - } catch (error: any) { - this.logger.error(error.message); - } - return []; - } - async sendApplicationCreatedNotifications( payload: CommunityApplicationCreatedEventPayload ): Promise[]> { - return this.sendNotifications( + return this.buildAndSend( payload, this.communityApplicationCreatedNotificationBuilder ); @@ -138,7 +111,7 @@ export class NotificationService { async sendInvitationCreatedNotifications( payload: CommunityInvitationCreatedEventPayload ): Promise[]> { - return this.sendNotifications( + return this.buildAndSend( payload, this.communityInvitationCreatedNotificationBuilder ); @@ -147,7 +120,7 @@ export class NotificationService { async sendVirtualContributorInvitationCreatedNotifications( payload: CommunityInvitationVirtualContributorCreatedEventPayload ): Promise[]> { - return this.sendNotifications( + return this.buildAndSend( payload, this.communityInvitationvirtualContributorCreatedNotificationBuilder ); @@ -156,7 +129,7 @@ export class NotificationService { async sendCommunityPlatformInvitationCreatedNotifications( payload: CommunityPlatformInvitationCreatedEventPayload ): Promise[]> { - return this.sendNotifications( + return this.buildAndSend( payload, this.communityPlatformInvitationCreatedNotificationBuilder ); @@ -165,7 +138,7 @@ export class NotificationService { async sendCommunityNewMemberNotifications( payload: CommunityNewMemberPayload ): Promise[]> { - return this.sendNotifications( + return this.buildAndSend( payload, this.communityNewMemberNotificationBuilder ); @@ -174,7 +147,7 @@ export class NotificationService { async sendGlobalRoleChangeNotification( payload: PlatformGlobalRoleChangeEventPayload ): Promise[]> { - return this.sendNotifications( + return this.buildAndSend( payload, this.platformGlobalRoleChangeNotificationBuilder ); @@ -183,7 +156,7 @@ export class NotificationService { async sendUserRegisteredNotification( payload: PlatformUserRegistrationEventPayload ): Promise[]> { - return this.sendNotifications( + return this.buildAndSend( payload, this.platformUserRegisteredNotificationBuilder ); @@ -192,7 +165,7 @@ export class NotificationService { async sendUserRemovedNotification( payload: PlatformUserRemovedEventPayload ): Promise[]> { - return this.sendNotifications( + return this.buildAndSend( payload, this.platformUserRemovedNotificationBuilder ); @@ -201,7 +174,7 @@ export class NotificationService { async sendCommunicationUpdatedNotification( payload: CommunicationUpdateEventPayload ): Promise[]> { - return this.sendNotifications( + return this.buildAndSend( payload, this.communicationUpdatedNotificationBuilder ); @@ -210,7 +183,7 @@ export class NotificationService { async sendPlatformForumDiscussionCreatedNotification( payload: PlatformForumDiscussionCreatedEventPayload ): Promise[]> { - return this.sendNotifications( + return this.buildAndSend( payload, this.communicationDiscussionCreatedNotificationBuilder ); @@ -219,7 +192,7 @@ export class NotificationService { async sendPlatformForumDiscussionCommentNotification( payload: PlatformForumDiscussionCommentEventPayload ): Promise[]> { - return this.sendNotifications( + return this.buildAndSend( payload, this.platformForumDiscussionCommentNotificationBuilder ); @@ -228,7 +201,7 @@ export class NotificationService { async sendCommunicationUserMessageNotification( payload: CommunicationUserMessageEventPayload ): Promise[]> { - return this.sendNotifications( + return this.buildAndSend( payload, this.communicationUserMessageNotificationBuilder ); @@ -237,7 +210,7 @@ export class NotificationService { async sendCommunicationOrganizationMessageNotification( payload: CommunicationOrganizationMessageEventPayload ): Promise[]> { - return this.sendNotifications( + return this.buildAndSend( payload, this.communicationOrganizationMessageNotificationBuilder ); @@ -246,7 +219,7 @@ export class NotificationService { async sendCommunicationCommunityLeadsMessageNotification( payload: CommunicationCommunityLeadsMessageEventPayload ): Promise[]> { - return this.sendNotifications( + return this.buildAndSend( payload, this.communicationCommunityLeadsMessageNotificationBuilder ); @@ -255,7 +228,7 @@ export class NotificationService { async sendCommunicationUserMentionNotification( payload: CommunicationUserMentionEventPayload ): Promise[]> { - return this.sendNotifications( + return this.buildAndSend( payload, this.communicationUserMentionNotificationBuilder ); @@ -264,7 +237,7 @@ export class NotificationService { async sendCommunicationOrganizationMentionNotification( payload: CommunicationOrganizationMentionEventPayload ): Promise[]> { - return this.sendNotifications( + return this.buildAndSend( payload, this.communicationOrganizationMentionNotificationBuilder ); @@ -273,7 +246,7 @@ export class NotificationService { async sendPostCreatedNotification( payload: CollaborationPostCreatedEventPayload ): Promise[]> { - return this.sendNotifications( + return this.buildAndSend( payload, this.collaborationPostCreatedNotificationBuilder ); @@ -282,7 +255,7 @@ export class NotificationService { async sendWhiteboardCreatedNotification( payload: CollaborationWhiteboardCreatedEventPayload ): Promise[]> { - return this.sendNotifications( + return this.buildAndSend( payload, this.collaborationWhiteboardCreatedNotificationBuilder ); @@ -291,7 +264,7 @@ export class NotificationService { async sendPostCommentCreatedNotification( payload: CollaborationPostCommentEventPayload ): Promise[]> { - return this.sendNotifications( + return this.buildAndSend( payload, this.collaborationPostCommentNotificationBuilder ); @@ -300,7 +273,7 @@ export class NotificationService { async sendDiscussionCommentCreatedNotification( payload: CollaborationDiscussionCommentEventPayload ): Promise[]> { - return this.sendNotifications( + return this.buildAndSend( payload, this.collaborationDiscussionCommentNotificationBuilder ); @@ -309,7 +282,7 @@ export class NotificationService { async sendCalloutPublishedNotification( payload: CollaborationCalloutPublishedEventPayload ): Promise[]> { - return this.sendNotifications( + return this.buildAndSend( payload, this.collaborationCalloutPublishedNotificationBuilder ); @@ -318,19 +291,40 @@ export class NotificationService { async sendCommentReplyNotification( payload: CommentReplyEventPayload ): Promise[]> { - return this.sendNotifications( - payload, - this.commentReplyNotificationBuilder - ); + return this.buildAndSend(payload, this.commentReplyNotificationBuilder); } - async sendSpaceCreatedNotification( + async buildAndSendSpaceCreatedNotification( payload: SpaceCreatedEventPayload ): Promise[]> { - return this.sendNotifications( - payload, - this.spaceCreatedNotificationBuilder - ); + return this.buildAndSend(payload, this.spaceCreatedNotificationBuilder); + } + + private async buildAndSend( + payload: BaseEventPayload, + notificationBuilder: INotificationBuilder + ): Promise[]> { + const notificationsEnabled = + await this.alkemioClientAdapter.areNotificationsEnabled(); + if (!notificationsEnabled) { + this.logger.verbose?.( + 'Notification disabled. No notifications are going to be built.', + LogContext.NOTIFICATIONS + ); + + return []; + } + + const notifications = await notificationBuilder.build(payload); + + try { + return Promise.allSettled( + notifications.map(x => this.sendNotification(x)) + ); + } catch (error: any) { + this.logger.error(error.message); + } + return []; } private async sendNotification( diff --git a/service/src/services/event-subjects/base.event.subject.ts b/service/src/services/event-subjects/base.event.subject.ts new file mode 100644 index 00000000..532f9f6d --- /dev/null +++ b/service/src/services/event-subjects/base.event.subject.ts @@ -0,0 +1,17 @@ +import { BaseEventPayload } from '@alkemio/notifications-lib'; +import { NotificationBuilder } from '@src/services/builders'; +import { EventSubject } from './event.subject'; + +export abstract class BaseEventSubject + implements EventSubject +{ + protected readonly builders: NotificationBuilder[] = []; + + protected registerBuilders(builders: NotificationBuilder[]): void { + this.builders.push(...builders); + } + + notifyAll(event: T): void { + this.builders.forEach(builder => builder.buildAndSend(event)); + } +} diff --git a/service/src/services/event-subjects/callout.published.event.subject.ts b/service/src/services/event-subjects/callout.published.event.subject.ts new file mode 100644 index 00000000..c56c72ef --- /dev/null +++ b/service/src/services/event-subjects/callout.published.event.subject.ts @@ -0,0 +1,14 @@ +import { CollaborationCalloutPublishedEventPayload } from '@alkemio/notifications-lib'; +import { Injectable } from '@nestjs/common'; +import { CalloutPublishedInAppNotificationBuilder } from '../builders/in-app'; +import { BaseEventSubject } from './base.event.subject'; + +@Injectable() +export class CalloutPublishedEventSubject extends BaseEventSubject { + constructor( + private readonly calloutPublishedBuilder: CalloutPublishedInAppNotificationBuilder + ) { + super(); + this.registerBuilders([this.calloutPublishedBuilder]); + } +} diff --git a/service/src/services/event-subjects/community.new.contributor.event.subject.ts b/service/src/services/event-subjects/community.new.contributor.event.subject.ts new file mode 100644 index 00000000..7a00c10b --- /dev/null +++ b/service/src/services/event-subjects/community.new.contributor.event.subject.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@nestjs/common'; +import { CommunityNewMemberPayload } from '@alkemio/notifications-lib'; +import { CommunityNewContributorInAppNotificationBuilder } from '../builders/in-app'; +import { BaseEventSubject } from './base.event.subject'; + +@Injectable() +export class CommunityNewContributorEventSubject extends BaseEventSubject { + constructor( + private readonly newContributorBuilder: CommunityNewContributorInAppNotificationBuilder + ) { + super(); + this.registerBuilders([this.newContributorBuilder]); + } +} diff --git a/service/src/services/event-subjects/contributor.mentioned.event.subject.ts b/service/src/services/event-subjects/contributor.mentioned.event.subject.ts new file mode 100644 index 00000000..fd691e6a --- /dev/null +++ b/service/src/services/event-subjects/contributor.mentioned.event.subject.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@nestjs/common'; +import { CommunicationUserMentionEventPayload } from '@alkemio/notifications-lib'; +import { ContributorMentionedInAppNotificationBuilder } from '../builders/in-app'; +import { BaseEventSubject } from './base.event.subject'; + +@Injectable() +export class ContributorMentionedEventSubject extends BaseEventSubject { + constructor( + private readonly contributorMentionedBuilder: ContributorMentionedInAppNotificationBuilder + ) { + super(); + this.registerBuilders([this.contributorMentionedBuilder]); + } +} diff --git a/service/src/services/event-subjects/event.subject.ts b/service/src/services/event-subjects/event.subject.ts new file mode 100644 index 00000000..cb28ac76 --- /dev/null +++ b/service/src/services/event-subjects/event.subject.ts @@ -0,0 +1,5 @@ +import { BaseEventPayload } from '@alkemio/notifications-lib'; + +export interface EventSubject { + notifyAll(event: BaseEventPayload): void; +} diff --git a/service/src/services/event-subjects/index.ts b/service/src/services/event-subjects/index.ts new file mode 100644 index 00000000..75facc2b --- /dev/null +++ b/service/src/services/event-subjects/index.ts @@ -0,0 +1,3 @@ +export * from './callout.published.event.subject'; +export * from './community.new.contributor.event.subject'; +export * from './contributor.mentioned.event.subject'; diff --git a/service/src/templates/comment.reply.js b/service/src/templates/comment.reply.js index 6323849b..2d176406 100644 --- a/service/src/templates/comment.reply.js +++ b/service/src/templates/comment.reply.js @@ -12,10 +12,10 @@ module.exports = () => ({ html: `{% extends "src/templates/_layouts/email-transactional.html" %} {% block content %}Hi {{recipient.firstName}},

- {{reply.createdBy}} replied to your comment on "{{comment.commentOrigin}}": -

-
"{{reply.message}}"
-

+ {{reply.createdBy}} replied to your comment on "{{comment.commentOrigin}}": +
+
{{reply.message}}
+
HAVE A LOOK!

{% endblock %} diff --git a/service/src/templates/platform.forum.discussion.created.js b/service/src/templates/platform.forum.discussion.created.js index 2e779bd0..9e5e1d1e 100644 --- a/service/src/templates/platform.forum.discussion.created.js +++ b/service/src/templates/platform.forum.discussion.created.js @@ -11,7 +11,7 @@ module.exports = () => ({ subject: 'New discussion created: {{discussion.displayName}}', html: `{% extends "src/templates/_layouts/email-transactional.html" %} {% block content %}Hi {{recipient.firstName}},

- {{createdBy.firstName}} created a new discussion in the Alkemio Forum: "{{discussion.displayName}}" + {{createdBy.firstName}} created a new post in the Alkemio Forum: "{{discussion.displayName}}"

HAVE A LOOK!

{% endblock %} diff --git a/service/src/types/index.d.ts b/service/src/types/index.d.ts index 800e3f9f..d9348892 100644 --- a/service/src/types/index.d.ts +++ b/service/src/types/index.d.ts @@ -1,2 +1,3 @@ export * from './notifme.sdk'; export * from './notification.template.type'; +export * from './rmq.connection.error'; diff --git a/service/src/types/rmq.connection.error.ts b/service/src/types/rmq.connection.error.ts new file mode 100644 index 00000000..d094fab4 --- /dev/null +++ b/service/src/types/rmq.connection.error.ts @@ -0,0 +1,14 @@ +export class RMQConnectionError { + err!: { + stack: string; + message: string; + }; + url!: { + protocol: string; + hostname: string; + username: string; + password: string; + port: number; + heartbeat: number; + }; +}