diff --git a/app/scenes/Settings/Notifications.tsx b/app/scenes/Settings/Notifications.tsx index b66c140c5..7e5eb88f8 100644 --- a/app/scenes/Settings/Notifications.tsx +++ b/app/scenes/Settings/Notifications.tsx @@ -58,7 +58,7 @@ function Notifications() { ), }, { - event: NotificationEventType.Mentioned, + event: NotificationEventType.MentionedInComment, icon: , title: t("Mentioned"), description: t( diff --git a/plugins/webhooks/server/tasks/DeliverWebhookTask.ts b/plugins/webhooks/server/tasks/DeliverWebhookTask.ts index ea6627eba..6b1c32056 100644 --- a/plugins/webhooks/server/tasks/DeliverWebhookTask.ts +++ b/plugins/webhooks/server/tasks/DeliverWebhookTask.ts @@ -102,6 +102,7 @@ export default class DeliverWebhookTask extends BaseTask { case "subscriptions.create": case "subscriptions.delete": case "authenticationProviders.update": + case "notifications.create": // Ignored return; case "users.create": diff --git a/server/commands/subscriptionCreator.ts b/server/commands/subscriptionCreator.ts index c7843d0be..1fa9adeed 100644 --- a/server/commands/subscriptionCreator.ts +++ b/server/commands/subscriptionCreator.ts @@ -1,5 +1,7 @@ import { Transaction } from "sequelize"; -import { Subscription, Event, User } from "@server/models"; +import { sequelize } from "@server/database/sequelize"; +import { Subscription, Event, User, Document } from "@server/models"; +import { DocumentEvent, RevisionEvent } from "@server/types"; type Props = { /** The user creating the subscription */ @@ -72,3 +74,32 @@ export default async function subscriptionCreator({ return subscription; } + +/** + * Create any new subscriptions that might be missing for collaborators in the + * document on publish and revision creation. This does mean that there is a + * short period of time where the user is not subscribed after editing until a + * revision is created. + * + * @param document The document to create subscriptions for + * @param event The event that triggered the subscription creation + */ +export const createSubscriptionsForDocument = async ( + document: Document, + event: DocumentEvent | RevisionEvent +): Promise => { + await sequelize.transaction(async (transaction) => { + const users = await document.collaborators({ transaction }); + + for (const user of users) { + await subscriptionCreator({ + user, + documentId: document.id, + event: "documents.update", + resubscribe: false, + transaction, + ip: event.ip, + }); + } + }); +}; diff --git a/server/emails/templates/CollectionCreatedEmail.tsx b/server/emails/templates/CollectionCreatedEmail.tsx index f4c60b673..a8b773c6d 100644 --- a/server/emails/templates/CollectionCreatedEmail.tsx +++ b/server/emails/templates/CollectionCreatedEmail.tsx @@ -1,9 +1,8 @@ import * as React from "react"; import { NotificationEventType } from "@shared/types"; -import env from "@server/env"; -import { Collection, User } from "@server/models"; +import { Collection, Notification, User } from "@server/models"; import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper"; -import BaseEmail from "./BaseEmail"; +import BaseEmail, { EmailProps } from "./BaseEmail"; import Body from "./components/Body"; import Button from "./components/Button"; import EmailTemplate from "./components/EmailLayout"; @@ -12,9 +11,9 @@ import Footer from "./components/Footer"; import Header from "./components/Header"; import Heading from "./components/Heading"; -type InputProps = { - to: string; +type InputProps = EmailProps & { userId: string; + teamUrl: string; collectionId: string; }; @@ -33,6 +32,20 @@ export default class CollectionCreatedEmail extends BaseEmail< InputProps, BeforeSend > { + public constructor(notification: Notification) { + super( + { + to: notification.user.email, + userId: notification.userId, + collectionId: notification.collectionId, + teamUrl: notification.team.url, + }, + { + notificationId: notification.id, + } + ); + } + protected async beforeSend({ userId, collectionId }: Props) { const collection = await Collection.scope("withUser").findByPk( collectionId @@ -41,15 +54,10 @@ export default class CollectionCreatedEmail extends BaseEmail< return false; } - const user = await User.findByPk(userId); - if (!user) { - return false; - } - return { collection, unsubscribeUrl: NotificationSettingsHelper.unsubscribeUrl( - user, + await User.findByPk(userId, { rejectOnEmpty: true }), NotificationEventType.CreateCollection ), }; @@ -63,17 +71,17 @@ export default class CollectionCreatedEmail extends BaseEmail< return `${collection.user.name} created a collection`; } - protected renderAsText({ collection }: Props) { + protected renderAsText({ teamUrl, collection }: Props) { return ` ${collection.name} ${collection.user.name} created the collection "${collection.name}" -Open Collection: ${env.URL}${collection.url} +Open Collection: ${teamUrl}${collection.url} `; } - protected render({ collection, unsubscribeUrl }: Props) { + protected render({ collection, teamUrl, unsubscribeUrl }: Props) { return (
@@ -86,7 +94,7 @@ Open Collection: ${env.URL}${collection.url}

-

diff --git a/server/emails/templates/CommentCreatedEmail.tsx b/server/emails/templates/CommentCreatedEmail.tsx index a56c36878..cdffbdbe0 100644 --- a/server/emails/templates/CommentCreatedEmail.tsx +++ b/server/emails/templates/CommentCreatedEmail.tsx @@ -1,9 +1,18 @@ import inlineCss from "inline-css"; import * as React from "react"; import { NotificationEventType } from "@shared/types"; +import { Day } from "@shared/utils/time"; import env from "@server/env"; -import { Comment, Document, User } from "@server/models"; +import { + Collection, + Comment, + Document, + Notification, + User, +} from "@server/models"; +import DocumentHelper from "@server/models/helpers/DocumentHelper"; import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper"; +import ProsemirrorHelper from "@server/models/helpers/ProsemirrorHelper"; import BaseEmail, { EmailProps } from "./BaseEmail"; import Body from "./components/Body"; import Button from "./components/Button"; @@ -18,17 +27,16 @@ type InputProps = EmailProps & { userId: string; documentId: string; actorName: string; - isReply: boolean; commentId: string; - collectionName: string | undefined; teamUrl: string; - content: string; }; type BeforeSend = { document: Document; + collection: Collection; body: string | undefined; isFirstComment: boolean; + isReply: boolean; unsubscribeUrl: string; }; @@ -42,19 +50,35 @@ export default class CommentCreatedEmail extends BaseEmail< InputProps, BeforeSend > { - protected async beforeSend({ - documentId, - userId, - commentId, - content, - }: InputProps) { + public constructor(notification: Notification) { + super( + { + to: notification.user.email, + userId: notification.userId, + documentId: notification.documentId, + teamUrl: notification.team.url, + actorName: notification.actor.name, + commentId: notification.commentId, + }, + { + notificationId: notification.id, + } + ); + } + + protected async beforeSend({ documentId, userId, commentId }: InputProps) { const document = await Document.unscoped().findByPk(documentId); if (!document) { return false; } - const user = await User.findByPk(userId); - if (!user) { + const collection = await document.$get("collection"); + if (!collection) { + return false; + } + + const comment = await Comment.findByPk(commentId); + if (!comment) { return false; } @@ -63,11 +87,23 @@ export default class CommentCreatedEmail extends BaseEmail< where: { documentId }, order: [["createdAt", "ASC"]], }); - const isFirstComment = firstComment?.id === commentId; - // inline all css so that it works in as many email providers as possible. let body; + let content = ProsemirrorHelper.toHTML( + ProsemirrorHelper.toProsemirror(comment.data), + { + centered: false, + } + ); + + content = await DocumentHelper.attachmentsToSignedUrls( + content, + document.teamId, + (4 * Day) / 1000 + ); + if (content) { + // inline all css so that it works in as many email providers as possible. body = await inlineCss(content, { url: env.URL, applyStyleTags: true, @@ -76,12 +112,17 @@ export default class CommentCreatedEmail extends BaseEmail< }); } + const isReply = !!comment.parentCommentId; + const isFirstComment = firstComment?.id === commentId; + return { document, + collection, + isReply, isFirstComment, body, unsubscribeUrl: NotificationSettingsHelper.unsubscribeUrl( - user, + await User.findByPk(userId, { rejectOnEmpty: true }), NotificationEventType.CreateComment ), }; @@ -107,12 +148,12 @@ export default class CommentCreatedEmail extends BaseEmail< isReply, document, commentId, - collectionName, + collection, }: Props): string { return ` ${actorName} ${isReply ? "replied to a thread in" : "commented on"} "${ document.title - }"${collectionName ? `in the ${collectionName} collection` : ""}. + }"${collection.name ? `in the ${collection.name} collection` : ""}. Open Thread: ${teamUrl}${document.url}?commentId=${commentId} `; @@ -122,7 +163,7 @@ Open Thread: ${teamUrl}${document.url}?commentId=${commentId} document, actorName, isReply, - collectionName, + collection, teamUrl, commentId, unsubscribeUrl, @@ -139,7 +180,7 @@ Open Thread: ${teamUrl}${document.url}?commentId=${commentId}

{actorName} {isReply ? "replied to a thread in" : "commented on"}{" "} {document.title}{" "} - {collectionName ? `in the ${collectionName} collection` : ""}. + {collection.name ? `in the ${collection.name} collection` : ""}.

{body && ( <> diff --git a/server/emails/templates/CommentMentionedEmail.tsx b/server/emails/templates/CommentMentionedEmail.tsx index 4515ed130..0910f74cd 100644 --- a/server/emails/templates/CommentMentionedEmail.tsx +++ b/server/emails/templates/CommentMentionedEmail.tsx @@ -1,9 +1,18 @@ import inlineCss from "inline-css"; import * as React from "react"; import { NotificationEventType } from "@shared/types"; +import { Day } from "@shared/utils/time"; import env from "@server/env"; -import { Document, User } from "@server/models"; +import { + Collection, + Comment, + Document, + Notification, + User, +} from "@server/models"; +import DocumentHelper from "@server/models/helpers/DocumentHelper"; import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper"; +import ProsemirrorHelper from "@server/models/helpers/ProsemirrorHelper"; import BaseEmail, { EmailProps } from "./BaseEmail"; import Body from "./components/Body"; import Button from "./components/Button"; @@ -19,13 +28,12 @@ type InputProps = EmailProps & { documentId: string; actorName: string; commentId: string; - collectionName: string | undefined; teamUrl: string; - content: string; }; type BeforeSend = { document: Document; + collection: Collection; body: string | undefined; unsubscribeUrl: string; }; @@ -40,20 +48,54 @@ export default class CommentMentionedEmail extends BaseEmail< InputProps, BeforeSend > { - protected async beforeSend({ documentId, userId, content }: InputProps) { + public constructor(notification: Notification) { + super( + { + to: notification.user.email, + userId: notification.userId, + documentId: notification.documentId, + teamUrl: notification.team.url, + actorName: notification.actor.name, + commentId: notification.commentId, + }, + { + notificationId: notification.id, + } + ); + } + + protected async beforeSend({ documentId, commentId, userId }: InputProps) { const document = await Document.unscoped().findByPk(documentId); if (!document) { return false; } - const user = await User.findByPk(userId); - if (!user) { + const collection = await document.$get("collection"); + if (!collection) { + return false; + } + + const comment = await Comment.findByPk(commentId); + if (!comment) { return false; } - // inline all css so that it works in as many email providers as possible. let body; + let content = ProsemirrorHelper.toHTML( + ProsemirrorHelper.toProsemirror(comment.data), + { + centered: false, + } + ); + + content = await DocumentHelper.attachmentsToSignedUrls( + content, + document.teamId, + (4 * Day) / 1000 + ); + if (content) { + // inline all css so that it works in as many email providers as possible. body = await inlineCss(content, { url: env.URL, applyStyleTags: true, @@ -64,10 +106,11 @@ export default class CommentMentionedEmail extends BaseEmail< return { document, + collection, body, unsubscribeUrl: NotificationSettingsHelper.unsubscribeUrl( - user, - NotificationEventType.Mentioned + await User.findByPk(userId, { rejectOnEmpty: true }), + NotificationEventType.MentionedInComment ), }; } @@ -89,11 +132,11 @@ export default class CommentMentionedEmail extends BaseEmail< teamUrl, document, commentId, - collectionName, + collection, }: Props): string { return ` ${actorName} mentioned you in a comment on "${document.title}"${ - collectionName ? `in the ${collectionName} collection` : "" + collection.name ? `in the ${collection.name} collection` : "" }. Open Thread: ${teamUrl}${document.url}?commentId=${commentId} @@ -102,8 +145,8 @@ Open Thread: ${teamUrl}${document.url}?commentId=${commentId} protected render({ document, + collection, actorName, - collectionName, teamUrl, commentId, unsubscribeUrl, @@ -120,7 +163,7 @@ Open Thread: ${teamUrl}${document.url}?commentId=${commentId}

{actorName} mentioned you in a comment on{" "} {document.title}{" "} - {collectionName ? `in the ${collectionName} collection` : ""}. + {collection.name ? `in the ${collection.name} collection` : ""}.

{body && ( <> diff --git a/server/emails/templates/DocumentMentionedEmail.tsx b/server/emails/templates/DocumentMentionedEmail.tsx index 0ee36d507..766c1e715 100644 --- a/server/emails/templates/DocumentMentionedEmail.tsx +++ b/server/emails/templates/DocumentMentionedEmail.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { Document } from "@server/models"; +import { Document, Notification } from "@server/models"; import BaseEmail, { EmailProps } from "./BaseEmail"; import Body from "./components/Body"; import Button from "./components/Button"; @@ -11,7 +11,6 @@ type InputProps = EmailProps & { documentId: string; actorName: string; teamUrl: string; - mentionId: string; }; type BeforeSend = { @@ -27,6 +26,20 @@ export default class DocumentMentionedEmail extends BaseEmail< InputProps, BeforeSend > { + public constructor(notification: Notification) { + super( + { + to: notification.user.email, + documentId: notification.documentId, + teamUrl: notification.team.url, + actorName: notification.actor.name, + }, + { + notificationId: notification.id, + } + ); + } + protected async beforeSend({ documentId }: InputProps) { const document = await Document.unscoped().findByPk(documentId); if (!document) { @@ -48,23 +61,18 @@ export default class DocumentMentionedEmail extends BaseEmail< return actorName; } - protected renderAsText({ - actorName, - teamUrl, - document, - mentionId, - }: Props): string { + protected renderAsText({ actorName, teamUrl, document }: Props): string { return ` You were mentioned ${actorName} mentioned you in the document “${document.title}”. -Open Document: ${teamUrl}${document.url}?mentionId=${mentionId} +Open Document: ${teamUrl}${document.url} `; } - protected render({ document, actorName, teamUrl, mentionId }: Props) { - const link = `${teamUrl}${document.url}?ref=notification-email&mentionId=${mentionId}`; + protected render({ document, actorName, teamUrl }: Props) { + const link = `${teamUrl}${document.url}?ref=notification-email`; return ( diff --git a/server/emails/templates/DocumentPublishedOrUpdatedEmail.tsx b/server/emails/templates/DocumentPublishedOrUpdatedEmail.tsx index 5895886e1..485cc7145 100644 --- a/server/emails/templates/DocumentPublishedOrUpdatedEmail.tsx +++ b/server/emails/templates/DocumentPublishedOrUpdatedEmail.tsx @@ -1,8 +1,16 @@ import inlineCss from "inline-css"; import * as React from "react"; import { NotificationEventType } from "@shared/types"; +import { Day } from "@shared/utils/time"; import env from "@server/env"; -import { Document, User } from "@server/models"; +import { + Document, + Collection, + User, + Revision, + Notification, +} from "@server/models"; +import DocumentHelper from "@server/models/helpers/DocumentHelper"; import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper"; import BaseEmail, { EmailProps } from "./BaseEmail"; import Body from "./components/Body"; @@ -17,17 +25,17 @@ import Heading from "./components/Heading"; type InputProps = EmailProps & { userId: string; documentId: string; + revisionId?: string; actorName: string; - collectionName: string; eventType: | NotificationEventType.PublishDocument | NotificationEventType.UpdateDocument; teamUrl: string; - content?: string; }; type BeforeSend = { document: Document; + collection: Collection; unsubscribeUrl: string; body: string | undefined; }; @@ -42,38 +50,72 @@ export default class DocumentPublishedOrUpdatedEmail extends BaseEmail< InputProps, BeforeSend > { + public constructor(notification: Notification) { + super( + { + to: notification.user.email, + userId: notification.userId, + eventType: notification.event as + | NotificationEventType.PublishDocument + | NotificationEventType.UpdateDocument, + revisionId: notification.revisionId, + documentId: notification.documentId, + teamUrl: notification.team.url, + actorName: notification.actor.name, + }, + { + notificationId: notification.id, + } + ); + } + protected async beforeSend({ documentId, + revisionId, eventType, userId, - content, }: InputProps) { - const document = await Document.unscoped().findByPk(documentId); + const document = await Document.unscoped().findByPk(documentId, { + includeState: true, + }); if (!document) { return false; } - const user = await User.findByPk(userId); - if (!user) { + const collection = await document.$get("collection"); + if (!collection) { return false; } - // inline all css so that it works in as many email providers as possible. let body; - if (content) { - body = await inlineCss(content, { - url: env.URL, - applyStyleTags: true, - applyLinkTags: false, - removeStyleTags: true, - }); + if (revisionId) { + // generate the diff html for the email + const revision = await Revision.findByPk(revisionId); + + if (revision) { + const before = await revision.previous(); + const content = await DocumentHelper.toEmailDiff(before, revision, { + includeTitle: false, + centered: false, + signedUrls: (4 * Day) / 1000, + }); + + // inline all css so that it works in as many email providers as possible. + body = await inlineCss(content, { + url: env.URL, + applyStyleTags: true, + applyLinkTags: false, + removeStyleTags: true, + }); + } } return { document, + collection, body, unsubscribeUrl: NotificationSettingsHelper.unsubscribeUrl( - user, + await User.findByPk(userId, { rejectOnEmpty: true }), eventType ), }; @@ -102,7 +144,7 @@ export default class DocumentPublishedOrUpdatedEmail extends BaseEmail< actorName, teamUrl, document, - collectionName, + collection, eventType, }: Props): string { const eventName = this.eventName(eventType); @@ -110,7 +152,7 @@ export default class DocumentPublishedOrUpdatedEmail extends BaseEmail< return ` "${document.title}" ${eventName} -${actorName} ${eventName} the document "${document.title}", in the ${collectionName} collection. +${actorName} ${eventName} the document "${document.title}", in the ${collection.name} collection. Open Document: ${teamUrl}${document.url} `; @@ -119,7 +161,7 @@ Open Document: ${teamUrl}${document.url} protected render({ document, actorName, - collectionName, + collection, eventType, teamUrl, unsubscribeUrl, @@ -138,7 +180,7 @@ Open Document: ${teamUrl}${document.url}

{actorName} {eventName} the document{" "} - {document.title}, in the {collectionName}{" "} + {document.title}, in the {collection.name}{" "} collection.

{body && ( diff --git a/server/emails/templates/ExportFailureEmail.tsx b/server/emails/templates/ExportFailureEmail.tsx index d949ccc63..ea5b06232 100644 --- a/server/emails/templates/ExportFailureEmail.tsx +++ b/server/emails/templates/ExportFailureEmail.tsx @@ -26,14 +26,9 @@ type BeforeSendProps = { */ export default class ExportFailureEmail extends BaseEmail { protected async beforeSend({ userId }: Props) { - const user = await User.findByPk(userId); - if (!user) { - return false; - } - return { unsubscribeUrl: NotificationSettingsHelper.unsubscribeUrl( - user, + await User.findByPk(userId, { rejectOnEmpty: true }), NotificationEventType.ExportCompleted ), }; diff --git a/server/emails/templates/ExportSuccessEmail.tsx b/server/emails/templates/ExportSuccessEmail.tsx index f3bf48602..2f9aac5ef 100644 --- a/server/emails/templates/ExportSuccessEmail.tsx +++ b/server/emails/templates/ExportSuccessEmail.tsx @@ -29,14 +29,9 @@ type BeforeSendProps = { */ export default class ExportSuccessEmail extends BaseEmail { protected async beforeSend({ userId }: Props) { - const user = await User.findByPk(userId); - if (!user) { - return false; - } - return { unsubscribeUrl: NotificationSettingsHelper.unsubscribeUrl( - user, + await User.findByPk(userId, { rejectOnEmpty: true }), NotificationEventType.ExportCompleted ), }; diff --git a/server/emails/templates/InviteAcceptedEmail.tsx b/server/emails/templates/InviteAcceptedEmail.tsx index a48118201..737b618c8 100644 --- a/server/emails/templates/InviteAcceptedEmail.tsx +++ b/server/emails/templates/InviteAcceptedEmail.tsx @@ -27,14 +27,9 @@ type BeforeSendProps = { */ export default class InviteAcceptedEmail extends BaseEmail { protected async beforeSend({ inviterId }: Props) { - const inviter = await User.findByPk(inviterId); - if (!inviter) { - return false; - } - return { unsubscribeUrl: NotificationSettingsHelper.unsubscribeUrl( - inviter, + await User.findByPk(inviterId, { rejectOnEmpty: true }), NotificationEventType.InviteAccepted ), }; diff --git a/server/migrations/20230403120315-add-comment-to-notifications.js b/server/migrations/20230403120315-add-comment-to-notifications.js new file mode 100644 index 000000000..b8845ff6c --- /dev/null +++ b/server/migrations/20230403120315-add-comment-to-notifications.js @@ -0,0 +1,38 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addColumn("notifications", "commentId", { + type: Sequelize.UUID, + allowNull: true, + onDelete: "cascade", + references: { + model: "comments", + }, + }); + + await queryInterface.addColumn("notifications", "revisionId", { + type: Sequelize.UUID, + allowNull: true, + onDelete: "cascade", + references: { + model: "revisions", + }, + }); + + await queryInterface.addColumn("notifications", "collectionId", { + type: Sequelize.UUID, + allowNull: true, + onDelete: "cascade", + references: { + model: "collections", + }, + }); + }, + + down: async (queryInterface) => { + await queryInterface.removeColumn("notifications", "collectionId") + await queryInterface.removeColumn("notifications", "revisionId") + await queryInterface.removeColumn("notifications", "commentId") + } +}; diff --git a/server/models/Notification.ts b/server/models/Notification.ts index 3082638ea..2d0e62b55 100644 --- a/server/models/Notification.ts +++ b/server/models/Notification.ts @@ -1,3 +1,4 @@ +import type { SaveOptions } from "sequelize"; import { Table, ForeignKey, @@ -10,12 +11,42 @@ import { DataType, Default, AllowNull, + AfterSave, + Scopes, } from "sequelize-typescript"; +import { NotificationEventType } from "@shared/types"; +import Collection from "./Collection"; +import Comment from "./Comment"; import Document from "./Document"; +import Event from "./Event"; +import Revision from "./Revision"; import Team from "./Team"; import User from "./User"; import Fix from "./decorators/Fix"; +@Scopes(() => ({ + withTeam: { + include: [ + { + association: "team", + }, + ], + }, + withUser: { + include: [ + { + association: "user", + }, + ], + }, + withActor: { + include: [ + { + association: "actor", + }, + ], + }, +})) @Table({ tableName: "notifications", modelName: "notification", @@ -40,8 +71,8 @@ class Notification extends Model { @CreatedAt createdAt: Date; - @Column - event: string; + @Column(DataType.STRING) + event: NotificationEventType; // associations @@ -60,6 +91,14 @@ class Notification extends Model { @Column(DataType.UUID) actorId: string; + @BelongsTo(() => Comment, "commentId") + comment: Comment; + + @AllowNull + @ForeignKey(() => Comment) + @Column(DataType.UUID) + commentId: string; + @BelongsTo(() => Document, "documentId") document: Document; @@ -68,12 +107,49 @@ class Notification extends Model { @Column(DataType.UUID) documentId: string; + @BelongsTo(() => Revision, "revisionId") + revision: Revision; + + @AllowNull + @ForeignKey(() => Revision) + @Column(DataType.UUID) + revisionId: string; + + @BelongsTo(() => Collection, "collectionId") + collection: Collection; + + @AllowNull + @ForeignKey(() => Collection) + @Column(DataType.UUID) + collectionId: string; + @BelongsTo(() => Team, "teamId") team: Team; @ForeignKey(() => Team) @Column(DataType.UUID) teamId: string; + + @AfterSave + static async createEvent( + model: Notification, + options: SaveOptions + ) { + const params = { + name: "notifications.create", + userId: model.userId, + modelId: model.id, + teamId: model.teamId, + documentId: model.documentId, + actorId: model.actorId, + }; + + if (options.transaction) { + options.transaction.afterCommit(() => void Event.schedule(params)); + return; + } + await Event.schedule(params); + } } export default Notification; diff --git a/server/queues/processors/EmailsProcessor.ts b/server/queues/processors/EmailsProcessor.ts new file mode 100644 index 000000000..f30713610 --- /dev/null +++ b/server/queues/processors/EmailsProcessor.ts @@ -0,0 +1,51 @@ +import { NotificationEventType } from "@shared/types"; +import CollectionCreatedEmail from "@server/emails/templates/CollectionCreatedEmail"; +import CommentCreatedEmail from "@server/emails/templates/CommentCreatedEmail"; +import CommentMentionedEmail from "@server/emails/templates/CommentMentionedEmail"; +import DocumentMentionedEmail from "@server/emails/templates/DocumentMentionedEmail"; +import DocumentPublishedOrUpdatedEmail from "@server/emails/templates/DocumentPublishedOrUpdatedEmail"; +import { Notification } from "@server/models"; +import { Event, NotificationEvent } from "@server/types"; +import BaseProcessor from "./BaseProcessor"; + +export default class NotificationsProcessor extends BaseProcessor { + static applicableEvents: Event["name"][] = ["notifications.create"]; + + async perform(event: NotificationEvent) { + const notification = await Notification.scope([ + "withTeam", + "withUser", + "withActor", + ]).findByPk(event.modelId); + if (!notification) { + return; + } + + switch (notification.event) { + case NotificationEventType.UpdateDocument: + case NotificationEventType.PublishDocument: { + await new DocumentPublishedOrUpdatedEmail(notification).schedule(); + return; + } + + case NotificationEventType.MentionedInDocument: { + await new DocumentMentionedEmail(notification).schedule(); + return; + } + + case NotificationEventType.MentionedInComment: { + await new CommentMentionedEmail(notification).schedule(); + return; + } + + case NotificationEventType.CreateCollection: { + await new CollectionCreatedEmail(notification).schedule(); + return; + } + + case NotificationEventType.CreateComment: { + await new CommentCreatedEmail(notification).schedule(); + } + } + } +} diff --git a/server/queues/processors/NotificationsProcessor.ts b/server/queues/processors/NotificationsProcessor.ts index 86c3ea5d6..3e0026cc4 100644 --- a/server/queues/processors/NotificationsProcessor.ts +++ b/server/queues/processors/NotificationsProcessor.ts @@ -1,26 +1,4 @@ -import { subHours } from "date-fns"; -import { differenceBy } from "lodash"; -import { Op } from "sequelize"; -import { NotificationEventType } from "@shared/types"; import { Minute } from "@shared/utils/time"; -import subscriptionCreator from "@server/commands/subscriptionCreator"; -import { sequelize } from "@server/database/sequelize"; -import CollectionCreatedEmail from "@server/emails/templates/CollectionCreatedEmail"; -import DocumentMentionedEmail from "@server/emails/templates/DocumentMentionedEmail"; -import DocumentPublishedOrUpdatedEmail from "@server/emails/templates/DocumentPublishedOrUpdatedEmail"; -import env from "@server/env"; -import Logger from "@server/logging/Logger"; -import { - Document, - Team, - Collection, - Notification, - Revision, - User, - View, -} from "@server/models"; -import DocumentHelper from "@server/models/helpers/DocumentHelper"; -import NotificationHelper from "@server/models/helpers/NotificationHelper"; import { CollectionEvent, RevisionEvent, @@ -28,8 +6,11 @@ import { DocumentEvent, CommentEvent, } from "@server/types"; -import CommentCreatedNotificationTask from "../tasks/CommentCreatedNotificationTask"; -import CommentUpdatedNotificationTask from "../tasks/CommentUpdatedNotificationTask"; +import CollectionCreatedNotificationsTask from "../tasks/CollectionCreatedNotificationsTask"; +import CommentCreatedNotificationsTask from "../tasks/CommentCreatedNotificationsTask"; +import CommentUpdatedNotificationsTask from "../tasks/CommentUpdatedNotificationsTask"; +import DocumentPublishedNotificationsTask from "../tasks/DocumentPublishedNotificationsTask"; +import RevisionCreatedNotificationsTask from "../tasks/RevisionCreatedNotificationsTask"; import BaseProcessor from "./BaseProcessor"; export default class NotificationsProcessor extends BaseProcessor { @@ -57,20 +38,8 @@ export default class NotificationsProcessor extends BaseProcessor { } } - async commentCreated(event: CommentEvent) { - await CommentCreatedNotificationTask.schedule(event, { - delay: Minute, - }); - } - - async commentUpdated(event: CommentEvent) { - await CommentUpdatedNotificationTask.schedule(event, { - delay: Minute, - }); - } - async documentPublished(event: DocumentEvent) { - // never send notifications when batch importing documents + // never send notifications when batch importing if ( "data" in event && "source" in event.data && @@ -79,304 +48,35 @@ export default class NotificationsProcessor extends BaseProcessor { return; } - const [collection, document, team] = await Promise.all([ - Collection.findByPk(event.collectionId), - Document.findByPk(event.documentId, { includeState: true }), - Team.findByPk(event.teamId), - ]); - - if (!document || !team || !collection) { - return; - } - - await this.createDocumentSubscriptions(document, event); - - // Send notifications to mentioned users first - const mentions = DocumentHelper.parseMentions(document); - const userIdsSentNotifications: string[] = []; - - for (const mention of mentions) { - const [recipient, actor] = await Promise.all([ - User.findByPk(mention.modelId), - User.findByPk(mention.actorId), - ]); - if ( - recipient && - actor && - recipient.id !== actor.id && - recipient.subscribedToEventType(NotificationEventType.Mentioned) - ) { - const notification = await Notification.create({ - event: event.name, - userId: recipient.id, - actorId: document.updatedBy.id, - teamId: team.id, - documentId: document.id, - }); - userIdsSentNotifications.push(recipient.id); - await new DocumentMentionedEmail( - { - to: recipient.email, - documentId: event.documentId, - actorName: actor.name, - teamUrl: team.url, - mentionId: mention.id, - }, - { notificationId: notification.id } - ).schedule(); - } - } - - const recipients = ( - await NotificationHelper.getDocumentNotificationRecipients( - document, - NotificationEventType.PublishDocument, - document.lastModifiedById, - false - ) - ).filter((recipient) => !userIdsSentNotifications.includes(recipient.id)); - - for (const recipient of recipients) { - const notify = await this.shouldNotify(document, recipient); - - if (notify) { - const notification = await Notification.create({ - event: event.name, - userId: recipient.id, - actorId: document.updatedBy.id, - teamId: team.id, - documentId: document.id, - }); - await new DocumentPublishedOrUpdatedEmail( - { - to: recipient.email, - userId: recipient.id, - eventType: NotificationEventType.PublishDocument, - documentId: document.id, - teamUrl: team.url, - actorName: document.updatedBy.name, - collectionName: collection.name, - }, - { notificationId: notification.id } - ).schedule(); - } - } + await DocumentPublishedNotificationsTask.schedule(event); } async revisionCreated(event: RevisionEvent) { - const [collection, document, revision, team] = await Promise.all([ - Collection.findByPk(event.collectionId), - Document.findByPk(event.documentId, { includeState: true }), - Revision.findByPk(event.modelId), - Team.findByPk(event.teamId), - ]); - - if (!document || !team || !revision || !collection) { - return; - } - - await this.createDocumentSubscriptions(document, event); - - // Send notifications to mentioned users first - const prev = await revision.previous(); - const oldMentions = prev ? DocumentHelper.parseMentions(prev) : []; - const newMentions = DocumentHelper.parseMentions(document); - const mentions = differenceBy(newMentions, oldMentions, "id"); - const userIdsSentNotifications: string[] = []; - - for (const mention of mentions) { - const [recipient, actor] = await Promise.all([ - User.findByPk(mention.modelId), - User.findByPk(mention.actorId), - ]); - if ( - recipient && - actor && - recipient.id !== actor.id && - recipient.subscribedToEventType(NotificationEventType.Mentioned) - ) { - const notification = await Notification.create({ - event: event.name, - userId: recipient.id, - actorId: document.updatedBy.id, - teamId: team.id, - documentId: document.id, - }); - userIdsSentNotifications.push(recipient.id); - await new DocumentMentionedEmail( - { - to: recipient.email, - documentId: event.documentId, - actorName: actor.name, - teamUrl: team.url, - mentionId: mention.id, - }, - { notificationId: notification.id } - ).schedule(); - } - } - - const recipients = ( - await NotificationHelper.getDocumentNotificationRecipients( - document, - NotificationEventType.UpdateDocument, - document.lastModifiedById, - true - ) - ).filter((recipient) => !userIdsSentNotifications.includes(recipient.id)); - if (!recipients.length) { - return; - } - - // generate the diff html for the email - const before = await revision.previous(); - const content = await DocumentHelper.toEmailDiff(before, revision, { - includeTitle: false, - centered: false, - signedUrls: 86400 * 4, - }); - if (!content) { - return; - } - - for (const recipient of recipients) { - const notify = await this.shouldNotify(document, recipient); - - if (notify) { - const notification = await Notification.create({ - event: event.name, - userId: recipient.id, - actorId: document.updatedBy.id, - teamId: team.id, - documentId: document.id, - }); - - await new DocumentPublishedOrUpdatedEmail( - { - to: recipient.email, - userId: recipient.id, - eventType: NotificationEventType.UpdateDocument, - documentId: document.id, - teamUrl: team.url, - actorName: document.updatedBy.name, - collectionName: collection.name, - content, - }, - { notificationId: notification.id } - ).schedule(); - } - } + await RevisionCreatedNotificationsTask.schedule(event); } async collectionCreated(event: CollectionEvent) { - const collection = await Collection.scope("withUser").findByPk( - event.collectionId - ); - - if (!collection || !collection.permission) { + // never send notifications when batch importing + if ( + "data" in event && + "source" in event.data && + event.data.source === "import" + ) { return; } - const recipients = await NotificationHelper.getCollectionNotificationRecipients( - collection, - NotificationEventType.CreateCollection - ); - - for (const recipient of recipients) { - // Suppress notifications for suspended users - if (recipient.isSuspended || !recipient.email) { - continue; - } - - await new CollectionCreatedEmail({ - to: recipient.email, - userId: recipient.id, - collectionId: collection.id, - }).schedule(); - } + await CollectionCreatedNotificationsTask.schedule(event); } - private shouldNotify = async ( - document: Document, - user: User - ): Promise => { - // Deliver only a single notification in a 12 hour window - const notification = await Notification.findOne({ - order: [["createdAt", "DESC"]], - where: { - userId: user.id, - documentId: document.id, - emailedAt: { - [Op.not]: null, - [Op.gte]: subHours(new Date(), 12), - }, - }, + async commentCreated(event: CommentEvent) { + await CommentCreatedNotificationsTask.schedule(event, { + delay: Minute, }); + } - if (notification) { - if (env.ENVIRONMENT === "development") { - Logger.info( - "processor", - `would have suppressed notification to ${user.id}, but not in development` - ); - } else { - Logger.info( - "processor", - `suppressing notification to ${user.id} as recently notified` - ); - return false; - } - } - - // If this recipient has viewed the document since the last update was made - // then we can avoid sending them a useless notification, yay. - const view = await View.findOne({ - where: { - userId: user.id, - documentId: document.id, - updatedAt: { - [Op.gt]: document.updatedAt, - }, - }, + async commentUpdated(event: CommentEvent) { + await CommentUpdatedNotificationsTask.schedule(event, { + delay: Minute, }); - - if (view) { - Logger.info( - "processor", - `suppressing notification to ${user.id} because update viewed` - ); - return false; - } - - return true; - }; - - /** - * Create any new subscriptions that might be missing for collaborators in the - * document on publish and revision creation. This does mean that there is a - * short period of time where the user is not subscribed after editing until a - * revision is created. - * - * @param document The document to create subscriptions for - * @param event The event that triggered the subscription creation - */ - private createDocumentSubscriptions = async ( - document: Document, - event: DocumentEvent | RevisionEvent - ): Promise => { - await sequelize.transaction(async (transaction) => { - const users = await document.collaborators({ transaction }); - - for (const user of users) { - await subscriptionCreator({ - user, - documentId: document.id, - event: "documents.update", - resubscribe: false, - transaction, - ip: event.ip, - }); - } - }); - }; + } } diff --git a/server/queues/tasks/CleanupOldNotificationsTask.ts b/server/queues/tasks/CleanupOldNotificationsTask.ts new file mode 100644 index 000000000..752f73de6 --- /dev/null +++ b/server/queues/tasks/CleanupOldNotificationsTask.ts @@ -0,0 +1,52 @@ +import { subMonths } from "date-fns"; +import { Op } from "sequelize"; +import Logger from "@server/logging/Logger"; +import { Notification } from "@server/models"; +import BaseTask, { TaskPriority, TaskSchedule } from "./BaseTask"; + +type Props = Record; + +export default class CleanupOldNotificationsTask extends BaseTask { + static cron = TaskSchedule.Daily; + + public async perform() { + Logger.info("task", `Permanently destroying old notifications…`); + let count; + + count = await Notification.destroy({ + where: { + createdAt: { + [Op.lt]: subMonths(new Date(), 12), + }, + }, + }); + + Logger.info( + "task", + `Destroyed ${count} notifications older than 12 months…` + ); + + count = await Notification.destroy({ + where: { + viewedAt: { + [Op.ne]: null, + }, + createdAt: { + [Op.lt]: subMonths(new Date(), 6), + }, + }, + }); + + Logger.info( + "task", + `Destroyed ${count} viewed notifications older than 6 months…` + ); + } + + public get options() { + return { + attempts: 1, + priority: TaskPriority.Background, + }; + } +} diff --git a/server/queues/tasks/CollectionCreatedNotificationsTask.ts b/server/queues/tasks/CollectionCreatedNotificationsTask.ts new file mode 100644 index 000000000..c9a85b525 --- /dev/null +++ b/server/queues/tasks/CollectionCreatedNotificationsTask.ts @@ -0,0 +1,44 @@ +import { NotificationEventType } from "@shared/types"; +import { Collection, Notification } from "@server/models"; +import NotificationHelper from "@server/models/helpers/NotificationHelper"; +import { CollectionEvent } from "@server/types"; +import BaseTask, { TaskPriority } from "./BaseTask"; + +export default class CollectionCreatedNotificationsTask extends BaseTask< + CollectionEvent +> { + public async perform(event: CollectionEvent) { + const collection = await Collection.findByPk(event.collectionId); + + // We only send notifications for collections visible to the entire team + if (!collection || !collection.permission) { + return; + } + + const recipients = await NotificationHelper.getCollectionNotificationRecipients( + collection, + NotificationEventType.CreateCollection + ); + + for (const recipient of recipients) { + // Suppress notifications for suspended users + if (recipient.isSuspended || !recipient.email) { + continue; + } + + await Notification.create({ + event: NotificationEventType.CreateCollection, + userId: recipient.id, + collectionId: collection.id, + actorId: collection.createdById, + teamId: collection.teamId, + }); + } + } + + public get options() { + return { + priority: TaskPriority.Background, + }; + } +} diff --git a/server/queues/tasks/CommentCreatedNotificationTask.ts b/server/queues/tasks/CommentCreatedNotificationTask.ts deleted file mode 100644 index c82cda9fd..000000000 --- a/server/queues/tasks/CommentCreatedNotificationTask.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { Node } from "prosemirror-model"; -import { NotificationEventType } from "@shared/types"; -import subscriptionCreator from "@server/commands/subscriptionCreator"; -import { sequelize } from "@server/database/sequelize"; -import { schema } from "@server/editor"; -import CommentCreatedEmail from "@server/emails/templates/CommentCreatedEmail"; -import CommentMentionedEmail from "@server/emails/templates/CommentMentionedEmail"; -import { Comment, Document, Notification, Team, User } from "@server/models"; -import DocumentHelper from "@server/models/helpers/DocumentHelper"; -import NotificationHelper from "@server/models/helpers/NotificationHelper"; -import ProsemirrorHelper from "@server/models/helpers/ProsemirrorHelper"; -import { CommentEvent } from "@server/types"; -import BaseTask, { TaskPriority } from "./BaseTask"; - -export default class CommentCreatedNotificationTask extends BaseTask< - CommentEvent -> { - public async perform(event: CommentEvent) { - const [document, comment, team] = await Promise.all([ - Document.scope("withCollection").findOne({ - where: { - id: event.documentId, - }, - }), - Comment.findByPk(event.modelId), - Team.findByPk(event.teamId), - ]); - if (!document || !comment || !team) { - return; - } - - // Commenting on a doc automatically creates a subscription to the doc - // if they haven't previously had one. - await sequelize.transaction(async (transaction) => { - await subscriptionCreator({ - user: comment.createdBy, - documentId: document.id, - event: "documents.update", - resubscribe: false, - transaction, - ip: event.ip, - }); - }); - - let content = ProsemirrorHelper.toHTML( - Node.fromJSON(schema, comment.data), - { - centered: false, - } - ); - if (!content) { - return; - } - - content = await DocumentHelper.attachmentsToSignedUrls( - content, - event.teamId, - 86400 * 4 - ); - - const mentions = ProsemirrorHelper.parseMentions( - ProsemirrorHelper.toProsemirror(comment.data) - ); - const userIdsSentNotifications: string[] = []; - - for (const mention of mentions) { - const [recipient, actor] = await Promise.all([ - User.findByPk(mention.modelId), - User.findByPk(mention.actorId), - ]); - if ( - recipient && - actor && - recipient.id !== actor.id && - recipient.subscribedToEventType(NotificationEventType.Mentioned) - ) { - const notification = await Notification.create({ - event: event.name, - userId: recipient.id, - actorId: actor.id, - teamId: team.id, - documentId: document.id, - }); - userIdsSentNotifications.push(recipient.id); - - await new CommentMentionedEmail( - { - to: recipient.email, - userId: recipient.id, - documentId: document.id, - teamUrl: team.url, - actorName: comment.createdBy.name, - commentId: comment.id, - content, - collectionName: document.collection?.name, - }, - { notificationId: notification.id } - ).schedule(); - } - } - - const recipients = ( - await NotificationHelper.getCommentNotificationRecipients( - document, - comment, - comment.createdById - ) - ).filter((recipient) => !userIdsSentNotifications.includes(recipient.id)); - - for (const recipient of recipients) { - const notification = await Notification.create({ - event: event.name, - userId: recipient.id, - actorId: comment.createdById, - teamId: team.id, - documentId: document.id, - }); - await new CommentCreatedEmail( - { - to: recipient.email, - userId: recipient.id, - documentId: document.id, - teamUrl: team.url, - isReply: !!comment.parentCommentId, - actorName: comment.createdBy.name, - commentId: comment.id, - content, - collectionName: document.collection?.name, - }, - { notificationId: notification.id } - ).schedule(); - } - } - - public get options() { - return { - attempts: 1, - priority: TaskPriority.Background, - }; - } -} diff --git a/server/queues/tasks/CommentCreatedNotificationsTask.ts b/server/queues/tasks/CommentCreatedNotificationsTask.ts new file mode 100644 index 000000000..1bcbae674 --- /dev/null +++ b/server/queues/tasks/CommentCreatedNotificationsTask.ts @@ -0,0 +1,91 @@ +import { NotificationEventType } from "@shared/types"; +import subscriptionCreator from "@server/commands/subscriptionCreator"; +import { sequelize } from "@server/database/sequelize"; +import { Comment, Document, Notification, User } from "@server/models"; +import NotificationHelper from "@server/models/helpers/NotificationHelper"; +import ProsemirrorHelper from "@server/models/helpers/ProsemirrorHelper"; +import { CommentEvent } from "@server/types"; +import BaseTask, { TaskPriority } from "./BaseTask"; + +export default class CommentCreatedNotificationsTask extends BaseTask< + CommentEvent +> { + public async perform(event: CommentEvent) { + const [document, comment] = await Promise.all([ + Document.scope("withCollection").findOne({ + where: { + id: event.documentId, + }, + }), + Comment.findByPk(event.modelId), + ]); + if (!document || !comment) { + return; + } + + // Commenting on a doc automatically creates a subscription to the doc + // if they haven't previously had one. + await sequelize.transaction(async (transaction) => { + await subscriptionCreator({ + user: comment.createdBy, + documentId: document.id, + event: "documents.update", + resubscribe: false, + transaction, + ip: event.ip, + }); + }); + + const mentions = ProsemirrorHelper.parseMentions( + ProsemirrorHelper.toProsemirror(comment.data) + ); + const userIdsMentioned: string[] = []; + + for (const mention of mentions) { + const recipient = await User.findByPk(mention.modelId); + + if ( + recipient && + recipient.id !== mention.actorId && + recipient.subscribedToEventType( + NotificationEventType.MentionedInComment + ) + ) { + await Notification.create({ + event: NotificationEventType.MentionedInComment, + userId: recipient.id, + actorId: mention.actorId, + teamId: document.teamId, + commentId: comment.id, + documentId: document.id, + }); + userIdsMentioned.push(recipient.id); + } + } + + const recipients = ( + await NotificationHelper.getCommentNotificationRecipients( + document, + comment, + comment.createdById + ) + ).filter((recipient) => !userIdsMentioned.includes(recipient.id)); + + for (const recipient of recipients) { + await Notification.create({ + event: NotificationEventType.CreateComment, + userId: recipient.id, + actorId: comment.createdById, + teamId: document.teamId, + commentId: comment.id, + documentId: document.id, + }); + } + } + + public get options() { + return { + priority: TaskPriority.Background, + }; + } +} diff --git a/server/queues/tasks/CommentUpdatedNotificationTask.ts b/server/queues/tasks/CommentUpdatedNotificationTask.ts deleted file mode 100644 index cf01da833..000000000 --- a/server/queues/tasks/CommentUpdatedNotificationTask.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { NotificationEventType } from "@shared/types"; -import CommentMentionedEmail from "@server/emails/templates/CommentMentionedEmail"; -import { Comment, Document, Notification, Team, User } from "@server/models"; -import DocumentHelper from "@server/models/helpers/DocumentHelper"; -import ProsemirrorHelper from "@server/models/helpers/ProsemirrorHelper"; -import { CommentEvent, CommentUpdateEvent } from "@server/types"; -import BaseTask, { TaskPriority } from "./BaseTask"; - -export default class CommentUpdatedNotificationTask extends BaseTask< - CommentEvent -> { - public async perform(event: CommentUpdateEvent) { - const [document, comment, team] = await Promise.all([ - Document.scope("withCollection").findOne({ - where: { - id: event.documentId, - }, - }), - Comment.findByPk(event.modelId), - Team.findByPk(event.teamId), - ]); - if (!document || !comment || !team) { - return; - } - - const mentions = ProsemirrorHelper.parseMentions( - ProsemirrorHelper.toProsemirror(comment.data) - ).filter((mention) => event.data.newMentionIds.includes(mention.id)); - if (mentions.length === 0) { - return; - } - - let content = ProsemirrorHelper.toHTML( - ProsemirrorHelper.toProsemirror(comment.data), - { - centered: false, - } - ); - if (!content) { - return; - } - - content = await DocumentHelper.attachmentsToSignedUrls( - content, - event.teamId, - 86400 * 4 - ); - - for (const mention of mentions) { - const [recipient, actor] = await Promise.all([ - User.findByPk(mention.modelId), - User.findByPk(mention.actorId), - ]); - if ( - recipient && - actor && - recipient.id !== actor.id && - recipient.subscribedToEventType(NotificationEventType.Mentioned) - ) { - const notification = await Notification.create({ - event: event.name, - userId: recipient.id, - actorId: actor.id, - teamId: team.id, - documentId: document.id, - }); - - await new CommentMentionedEmail( - { - to: recipient.email, - userId: recipient.id, - documentId: document.id, - teamUrl: team.url, - actorName: comment.createdBy.name, - commentId: comment.id, - content, - collectionName: document.collection?.name, - }, - { notificationId: notification.id } - ).schedule(); - } - } - } - - public get options() { - return { - attempts: 1, - priority: TaskPriority.Background, - }; - } -} diff --git a/server/queues/tasks/CommentUpdatedNotificationsTask.ts b/server/queues/tasks/CommentUpdatedNotificationsTask.ts new file mode 100644 index 000000000..7853627d9 --- /dev/null +++ b/server/queues/tasks/CommentUpdatedNotificationsTask.ts @@ -0,0 +1,57 @@ +import { NotificationEventType } from "@shared/types"; +import { Comment, Document, Notification, User } from "@server/models"; +import ProsemirrorHelper from "@server/models/helpers/ProsemirrorHelper"; +import { CommentEvent, CommentUpdateEvent } from "@server/types"; +import BaseTask, { TaskPriority } from "./BaseTask"; + +export default class CommentUpdatedNotificationsTask extends BaseTask< + CommentEvent +> { + public async perform(event: CommentUpdateEvent) { + const [document, comment] = await Promise.all([ + Document.scope("withCollection").findOne({ + where: { + id: event.documentId, + }, + }), + Comment.findByPk(event.modelId), + ]); + if (!document || !comment) { + return; + } + + const mentions = ProsemirrorHelper.parseMentions( + ProsemirrorHelper.toProsemirror(comment.data) + ).filter((mention) => event.data.newMentionIds.includes(mention.id)); + if (mentions.length === 0) { + return; + } + + for (const mention of mentions) { + const recipient = await User.findByPk(mention.modelId); + + if ( + recipient && + recipient.id !== mention.actorId && + recipient.subscribedToEventType( + NotificationEventType.MentionedInComment + ) + ) { + await Notification.create({ + event: NotificationEventType.MentionedInComment, + userId: recipient.id, + actorId: mention.actorId, + teamId: document.teamId, + documentId: document.id, + }); + } + } + } + + public get options() { + return { + attempts: 1, + priority: TaskPriority.Background, + }; + } +} diff --git a/server/queues/tasks/DocumentPublishedNotificationsTask.test.ts b/server/queues/tasks/DocumentPublishedNotificationsTask.test.ts new file mode 100644 index 000000000..c455fcfda --- /dev/null +++ b/server/queues/tasks/DocumentPublishedNotificationsTask.test.ts @@ -0,0 +1,137 @@ +import { NotificationEventType } from "@shared/types"; +import { Notification } from "@server/models"; +import { + buildDocument, + buildCollection, + buildUser, +} from "@server/test/factories"; +import { setupTestDatabase } from "@server/test/support"; +import DocumentPublishedNotificationsTask from "./DocumentPublishedNotificationsTask"; + +const ip = "127.0.0.1"; + +setupTestDatabase(); + +beforeEach(async () => { + jest.resetAllMocks(); +}); + +describe("documents.publish", () => { + test("should not send a notification to author", async () => { + const spy = jest.spyOn(Notification, "create"); + const user = await buildUser(); + const document = await buildDocument({ + teamId: user.teamId, + lastModifiedById: user.id, + }); + user.setNotificationEventType(NotificationEventType.PublishDocument); + await user.save(); + + const processor = new DocumentPublishedNotificationsTask(); + await processor.perform({ + name: "documents.publish", + documentId: document.id, + collectionId: document.collectionId, + teamId: document.teamId, + actorId: document.createdById, + data: { + title: document.title, + }, + ip, + }); + expect(spy).not.toHaveBeenCalled(); + }); + + test("should send a notification to other users in team", async () => { + const spy = jest.spyOn(Notification, "create"); + const user = await buildUser(); + const document = await buildDocument({ + teamId: user.teamId, + }); + user.setNotificationEventType(NotificationEventType.PublishDocument); + await user.save(); + + const processor = new DocumentPublishedNotificationsTask(); + await processor.perform({ + name: "documents.publish", + documentId: document.id, + collectionId: document.collectionId, + teamId: document.teamId, + actorId: document.createdById, + data: { + title: document.title, + }, + ip, + }); + expect(spy).toHaveBeenCalled(); + }); + + test("should send only one notification in a 12-hour window", async () => { + const spy = jest.spyOn(Notification, "create"); + const user = await buildUser(); + const document = await buildDocument({ + teamId: user.teamId, + createdById: user.id, + lastModifiedById: user.id, + }); + + const recipient = await buildUser({ + teamId: user.teamId, + }); + + user.setNotificationEventType(NotificationEventType.PublishDocument); + await user.save(); + + await Notification.create({ + event: NotificationEventType.PublishDocument, + actorId: user.id, + userId: recipient.id, + documentId: document.id, + teamId: recipient.teamId, + emailedAt: new Date(), + }); + + const processor = new DocumentPublishedNotificationsTask(); + await processor.perform({ + name: "documents.publish", + documentId: document.id, + collectionId: document.collectionId, + teamId: document.teamId, + actorId: document.createdById, + data: { + title: document.title, + }, + ip, + }); + expect(spy).toHaveBeenCalledTimes(1); + }); + + test("should not send a notification to users without collection access", async () => { + const spy = jest.spyOn(Notification, "create"); + const user = await buildUser(); + const collection = await buildCollection({ + teamId: user.teamId, + permission: null, + }); + const document = await buildDocument({ + teamId: user.teamId, + collectionId: collection.id, + }); + user.setNotificationEventType(NotificationEventType.PublishDocument); + await user.save(); + + const processor = new DocumentPublishedNotificationsTask(); + await processor.perform({ + name: "documents.publish", + documentId: document.id, + collectionId: document.collectionId, + teamId: document.teamId, + actorId: document.createdById, + data: { + title: document.title, + }, + ip, + }); + expect(spy).not.toHaveBeenCalled(); + }); +}); diff --git a/server/queues/tasks/DocumentPublishedNotificationsTask.ts b/server/queues/tasks/DocumentPublishedNotificationsTask.ts new file mode 100644 index 000000000..d27b66bb2 --- /dev/null +++ b/server/queues/tasks/DocumentPublishedNotificationsTask.ts @@ -0,0 +1,72 @@ +import { NotificationEventType } from "@shared/types"; +import { createSubscriptionsForDocument } from "@server/commands/subscriptionCreator"; +import { Document, Notification, User } from "@server/models"; +import DocumentHelper from "@server/models/helpers/DocumentHelper"; +import NotificationHelper from "@server/models/helpers/NotificationHelper"; +import { DocumentEvent } from "@server/types"; +import BaseTask, { TaskPriority } from "./BaseTask"; + +export default class DocumentPublishedNotificationsTask extends BaseTask< + DocumentEvent +> { + public async perform(event: DocumentEvent) { + const document = await Document.findByPk(event.documentId, { + includeState: true, + }); + if (!document) { + return; + } + + await createSubscriptionsForDocument(document, event); + + // Send notifications to mentioned users first + const mentions = DocumentHelper.parseMentions(document); + const userIdsMentioned: string[] = []; + + for (const mention of mentions) { + const recipient = await User.findByPk(mention.modelId); + + if ( + recipient && + recipient.id !== mention.actorId && + recipient.subscribedToEventType( + NotificationEventType.MentionedInDocument + ) + ) { + await Notification.create({ + event: NotificationEventType.MentionedInDocument, + userId: recipient.id, + actorId: document.updatedBy.id, + teamId: document.teamId, + documentId: document.id, + }); + userIdsMentioned.push(recipient.id); + } + } + + const recipients = ( + await NotificationHelper.getDocumentNotificationRecipients( + document, + NotificationEventType.PublishDocument, + document.lastModifiedById, + false + ) + ).filter((recipient) => !userIdsMentioned.includes(recipient.id)); + + for (const recipient of recipients) { + await Notification.create({ + event: NotificationEventType.PublishDocument, + userId: recipient.id, + actorId: document.updatedBy.id, + teamId: document.teamId, + documentId: document.id, + }); + } + } + + public get options() { + return { + priority: TaskPriority.Background, + }; + } +} diff --git a/server/queues/processors/NotificationsProcessor.test.ts b/server/queues/tasks/RevisionCreatedNotificationsTask.test.ts similarity index 64% rename from server/queues/processors/NotificationsProcessor.test.ts rename to server/queues/tasks/RevisionCreatedNotificationsTask.test.ts index 639033bf6..8e1a96d83 100644 --- a/server/queues/processors/NotificationsProcessor.test.ts +++ b/server/queues/tasks/RevisionCreatedNotificationsTask.test.ts @@ -1,5 +1,3 @@ -import { NotificationEventType } from "@shared/types"; -import DocumentPublishedOrUpdatedEmail from "@server/emails/templates/DocumentPublishedOrUpdatedEmail"; import { View, Subscription, @@ -7,13 +5,9 @@ import { Notification, Revision, } from "@server/models"; -import { - buildDocument, - buildCollection, - buildUser, -} from "@server/test/factories"; +import { buildDocument, buildUser } from "@server/test/factories"; import { setupTestDatabase } from "@server/test/support"; -import NotificationsProcessor from "./NotificationsProcessor"; +import RevisionCreatedNotificationsTask from "./RevisionCreatedNotificationsTask"; const ip = "127.0.0.1"; @@ -23,145 +17,9 @@ beforeEach(async () => { jest.resetAllMocks(); }); -describe("documents.publish", () => { - test("should not send a notification to author", async () => { - const schedule = jest.spyOn( - DocumentPublishedOrUpdatedEmail.prototype, - "schedule" - ); - - const user = await buildUser(); - const document = await buildDocument({ - teamId: user.teamId, - lastModifiedById: user.id, - }); - user.setNotificationEventType(NotificationEventType.PublishDocument); - await user.save(); - - const processor = new NotificationsProcessor(); - await processor.perform({ - name: "documents.publish", - documentId: document.id, - collectionId: document.collectionId, - teamId: document.teamId, - actorId: document.createdById, - data: { - title: document.title, - }, - ip, - }); - expect(schedule).not.toHaveBeenCalled(); - }); - - test("should send a notification to other users in team", async () => { - const schedule = jest.spyOn( - DocumentPublishedOrUpdatedEmail.prototype, - "schedule" - ); - const user = await buildUser(); - const document = await buildDocument({ - teamId: user.teamId, - }); - user.setNotificationEventType(NotificationEventType.PublishDocument); - await user.save(); - - const processor = new NotificationsProcessor(); - await processor.perform({ - name: "documents.publish", - documentId: document.id, - collectionId: document.collectionId, - teamId: document.teamId, - actorId: document.createdById, - data: { - title: document.title, - }, - ip, - }); - expect(schedule).toHaveBeenCalled(); - }); - - test("should send only one notification in a 12-hour window", async () => { - const schedule = jest.spyOn( - DocumentPublishedOrUpdatedEmail.prototype, - "schedule" - ); - const user = await buildUser(); - const document = await buildDocument({ - teamId: user.teamId, - createdById: user.id, - lastModifiedById: user.id, - }); - - const recipient = await buildUser({ - teamId: user.teamId, - }); - - user.setNotificationEventType(NotificationEventType.PublishDocument); - await user.save(); - - await Notification.create({ - actorId: user.id, - userId: recipient.id, - documentId: document.id, - teamId: recipient.teamId, - event: "documents.publish", - emailedAt: new Date(), - }); - - const processor = new NotificationsProcessor(); - await processor.perform({ - name: "documents.publish", - documentId: document.id, - collectionId: document.collectionId, - teamId: document.teamId, - actorId: document.createdById, - data: { - title: document.title, - }, - ip, - }); - expect(schedule).not.toHaveBeenCalled(); - }); - - test("should not send a notification to users without collection access", async () => { - const schedule = jest.spyOn( - DocumentPublishedOrUpdatedEmail.prototype, - "schedule" - ); - const user = await buildUser(); - const collection = await buildCollection({ - teamId: user.teamId, - permission: null, - }); - const document = await buildDocument({ - teamId: user.teamId, - collectionId: collection.id, - }); - user.setNotificationEventType(NotificationEventType.PublishDocument); - await user.save(); - - const processor = new NotificationsProcessor(); - await processor.perform({ - name: "documents.publish", - documentId: document.id, - collectionId: document.collectionId, - teamId: document.teamId, - actorId: document.createdById, - data: { - title: document.title, - }, - ip, - }); - expect(schedule).not.toHaveBeenCalled(); - }); -}); - describe("revisions.create", () => { test("should send a notification to other collaborators", async () => { - const schedule = jest.spyOn( - DocumentPublishedOrUpdatedEmail.prototype, - "schedule" - ); + const spy = jest.spyOn(Notification, "create"); const document = await buildDocument(); await Revision.createFromDocument(document); @@ -172,8 +30,8 @@ describe("revisions.create", () => { document.collaboratorIds = [collaborator.id]; await document.save(); - const processor = new NotificationsProcessor(); - await processor.perform({ + const task = new RevisionCreatedNotificationsTask(); + await task.perform({ name: "revisions.create", documentId: document.id, collectionId: document.collectionId, @@ -182,14 +40,11 @@ describe("revisions.create", () => { modelId: revision.id, ip, }); - expect(schedule).toHaveBeenCalled(); + expect(spy).toHaveBeenCalled(); }); test("should not send a notification if viewed since update", async () => { - const schedule = jest.spyOn( - DocumentPublishedOrUpdatedEmail.prototype, - "schedule" - ); + const spy = jest.spyOn(Notification, "create"); const document = await buildDocument(); await Revision.createFromDocument(document); document.text = "Updated body content"; @@ -204,8 +59,8 @@ describe("revisions.create", () => { documentId: document.id, }); - const processor = new NotificationsProcessor(); - await processor.perform({ + const task = new RevisionCreatedNotificationsTask(); + await task.perform({ name: "revisions.create", documentId: document.id, collectionId: document.collectionId, @@ -214,14 +69,11 @@ describe("revisions.create", () => { modelId: revision.id, ip, }); - expect(schedule).not.toHaveBeenCalled(); + expect(spy).not.toHaveBeenCalled(); }); test("should not send a notification to last editor", async () => { - const schedule = jest.spyOn( - DocumentPublishedOrUpdatedEmail.prototype, - "schedule" - ); + const spy = jest.spyOn(Notification, "create"); const user = await buildUser(); const document = await buildDocument({ teamId: user.teamId, @@ -232,8 +84,8 @@ describe("revisions.create", () => { document.updatedAt = new Date(); const revision = await Revision.createFromDocument(document); - const processor = new NotificationsProcessor(); - await processor.perform({ + const task = new RevisionCreatedNotificationsTask(); + await task.perform({ name: "revisions.create", documentId: document.id, collectionId: document.collectionId, @@ -242,14 +94,11 @@ describe("revisions.create", () => { modelId: revision.id, ip, }); - expect(schedule).not.toHaveBeenCalled(); + expect(spy).not.toHaveBeenCalled(); }); test("should send a notification for subscriptions, even to collaborator", async () => { - const schedule = jest.spyOn( - DocumentPublishedOrUpdatedEmail.prototype, - "schedule" - ); + const spy = jest.spyOn(Notification, "create"); const document = await buildDocument(); await Revision.createFromDocument(document); document.text = "Updated body content"; @@ -269,9 +118,9 @@ describe("revisions.create", () => { enabled: true, }); - const processor = new NotificationsProcessor(); + const task = new RevisionCreatedNotificationsTask(); - await processor.perform({ + await task.perform({ name: "revisions.create", documentId: document.id, collectionId: document.collectionId, @@ -281,7 +130,7 @@ describe("revisions.create", () => { ip, }); - expect(schedule).toHaveBeenCalled(); + expect(spy).toHaveBeenCalled(); }); test("should create subscriptions for collaborator", async () => { @@ -298,9 +147,9 @@ describe("revisions.create", () => { collaboratorIds: [collaborator0.id, collaborator1.id, collaborator2.id], }); - const processor = new NotificationsProcessor(); + const task = new RevisionCreatedNotificationsTask(); - await processor.perform({ + await task.perform({ name: "revisions.create", documentId: document.id, collectionId: document.collectionId, @@ -330,10 +179,7 @@ describe("revisions.create", () => { }); test("should not send multiple emails", async () => { - const schedule = jest.spyOn( - DocumentPublishedOrUpdatedEmail.prototype, - "schedule" - ); + const spy = jest.spyOn(Notification, "create"); const collaborator0 = await buildUser(); const collaborator1 = await buildUser({ teamId: collaborator0.teamId }); const collaborator2 = await buildUser({ teamId: collaborator0.teamId }); @@ -350,22 +196,10 @@ describe("revisions.create", () => { collaboratorIds: [collaborator0.id, collaborator1.id, collaborator2.id], }); - const processor = new NotificationsProcessor(); - - // Changing document will emit a `documents.update` event. - await processor.perform({ - name: "documents.update", - documentId: document.id, - collectionId: document.collectionId, - createdAt: document.updatedAt.toString(), - teamId: document.teamId, - data: { title: document.title, autosave: false, done: true }, - actorId: collaborator2.id, - ip, - }); + const task = new RevisionCreatedNotificationsTask(); // Those changes will also emit a `revisions.create` event. - await processor.perform({ + await task.perform({ name: "revisions.create", documentId: document.id, collectionId: document.collectionId, @@ -377,14 +211,11 @@ describe("revisions.create", () => { // This should send out 2 emails, one for each collaborator that did not // participate in the edit - expect(schedule).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenCalledTimes(2); }); test("should not create subscriptions if previously unsubscribed", async () => { - const schedule = jest.spyOn( - DocumentPublishedOrUpdatedEmail.prototype, - "schedule" - ); + const spy = jest.spyOn(Notification, "create"); const collaborator0 = await buildUser(); const collaborator1 = await buildUser({ teamId: collaborator0.teamId }); const collaborator2 = await buildUser({ teamId: collaborator0.teamId }); @@ -411,9 +242,9 @@ describe("revisions.create", () => { // `collaborator2` would no longer like to be notified. await subscription2.destroy(); - const processor = new NotificationsProcessor(); + const task = new RevisionCreatedNotificationsTask(); - await processor.perform({ + await task.perform({ name: "revisions.create", documentId: document.id, collectionId: document.collectionId, @@ -440,14 +271,11 @@ describe("revisions.create", () => { // One notification as one collaborator performed edit and the other is // unsubscribed - expect(schedule).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledTimes(1); }); test("should send a notification for subscriptions to non-collaborators", async () => { - const schedule = jest.spyOn( - DocumentPublishedOrUpdatedEmail.prototype, - "schedule" - ); + const spy = jest.spyOn(Notification, "create"); const document = await buildDocument(); const collaborator = await buildUser({ teamId: document.teamId }); const subscriber = await buildUser({ teamId: document.teamId }); @@ -470,9 +298,9 @@ describe("revisions.create", () => { enabled: true, }); - const processor = new NotificationsProcessor(); + const task = new RevisionCreatedNotificationsTask(); - await processor.perform({ + await task.perform({ name: "revisions.create", documentId: document.id, collectionId: document.collectionId, @@ -482,14 +310,12 @@ describe("revisions.create", () => { ip, }); - expect(schedule).toHaveBeenCalled(); + expect(spy).toHaveBeenCalled(); }); test("should not send a notification for subscriptions to collaborators if unsubscribed", async () => { - const schedule = jest.spyOn( - DocumentPublishedOrUpdatedEmail.prototype, - "schedule" - ); + const spy = jest.spyOn(Notification, "create"); + const document = await buildDocument(); await Revision.createFromDocument(document); document.text = "Updated body content"; @@ -514,9 +340,9 @@ describe("revisions.create", () => { subscription.destroy(); - const processor = new NotificationsProcessor(); + const task = new RevisionCreatedNotificationsTask(); - await processor.perform({ + await task.perform({ name: "revisions.create", documentId: document.id, collectionId: document.collectionId, @@ -527,14 +353,12 @@ describe("revisions.create", () => { }); // Should send notification to `collaborator` and not `subscriber`. - expect(schedule).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledTimes(1); }); test("should not send a notification for subscriptions to members outside of the team", async () => { - const schedule = jest.spyOn( - DocumentPublishedOrUpdatedEmail.prototype, - "schedule" - ); + const spy = jest.spyOn(Notification, "create"); + const document = await buildDocument(); await Revision.createFromDocument(document); document.text = "Updated body content"; @@ -562,9 +386,9 @@ describe("revisions.create", () => { enabled: true, }); - const processor = new NotificationsProcessor(); + const task = new RevisionCreatedNotificationsTask(); - await processor.perform({ + await task.perform({ name: "revisions.create", documentId: document.id, collectionId: document.collectionId, @@ -575,14 +399,12 @@ describe("revisions.create", () => { }); // Should send notification to `collaborator` and not `subscriber`. - expect(schedule).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledTimes(1); }); test("should not send a notification if viewed since update", async () => { - const schedule = jest.spyOn( - DocumentPublishedOrUpdatedEmail.prototype, - "schedule" - ); + const spy = jest.spyOn(Notification, "create"); + const document = await buildDocument(); const revision = await Revision.createFromDocument(document); const collaborator = await buildUser({ teamId: document.teamId }); @@ -594,9 +416,9 @@ describe("revisions.create", () => { documentId: document.id, }); - const processor = new NotificationsProcessor(); + const task = new RevisionCreatedNotificationsTask(); - await processor.perform({ + await task.perform({ name: "revisions.create", documentId: document.id, collectionId: document.collectionId, @@ -605,14 +427,12 @@ describe("revisions.create", () => { modelId: revision.id, ip, }); - expect(schedule).not.toHaveBeenCalled(); + expect(spy).not.toHaveBeenCalled(); }); test("should not send a notification to last editor", async () => { - const schedule = jest.spyOn( - DocumentPublishedOrUpdatedEmail.prototype, - "schedule" - ); + const spy = jest.spyOn(Notification, "create"); + const user = await buildUser(); const document = await buildDocument({ teamId: user.teamId, @@ -620,8 +440,8 @@ describe("revisions.create", () => { }); const revision = await Revision.createFromDocument(document); - const processor = new NotificationsProcessor(); - await processor.perform({ + const task = new RevisionCreatedNotificationsTask(); + await task.perform({ name: "revisions.create", documentId: document.id, collectionId: document.collectionId, @@ -630,6 +450,6 @@ describe("revisions.create", () => { modelId: revision.id, ip, }); - expect(schedule).not.toHaveBeenCalled(); + expect(spy).not.toHaveBeenCalled(); }); }); diff --git a/server/queues/tasks/RevisionCreatedNotificationsTask.ts b/server/queues/tasks/RevisionCreatedNotificationsTask.ts new file mode 100644 index 000000000..fedf58cfd --- /dev/null +++ b/server/queues/tasks/RevisionCreatedNotificationsTask.ts @@ -0,0 +1,145 @@ +import { subHours } from "date-fns"; +import { differenceBy } from "lodash"; +import { Op } from "sequelize"; +import { NotificationEventType } from "@shared/types"; +import { createSubscriptionsForDocument } from "@server/commands/subscriptionCreator"; +import env from "@server/env"; +import Logger from "@server/logging/Logger"; +import { Document, Revision, Notification, User, View } from "@server/models"; +import DocumentHelper from "@server/models/helpers/DocumentHelper"; +import NotificationHelper from "@server/models/helpers/NotificationHelper"; +import { RevisionEvent } from "@server/types"; +import BaseTask, { TaskPriority } from "./BaseTask"; + +export default class RevisionCreatedNotificationsTask extends BaseTask< + RevisionEvent +> { + public async perform(event: RevisionEvent) { + const [document, revision] = await Promise.all([ + Document.findByPk(event.documentId, { includeState: true }), + Revision.findByPk(event.modelId), + ]); + + if (!document || !revision) { + return; + } + + await createSubscriptionsForDocument(document, event); + + // Send notifications to mentioned users first + const before = await revision.previous(); + const oldMentions = before ? DocumentHelper.parseMentions(before) : []; + const newMentions = DocumentHelper.parseMentions(document); + const mentions = differenceBy(newMentions, oldMentions, "id"); + const userIdsMentioned: string[] = []; + + for (const mention of mentions) { + const recipient = await User.findByPk(mention.modelId); + if ( + recipient && + recipient.id !== mention.actorId && + recipient.subscribedToEventType( + NotificationEventType.MentionedInDocument + ) + ) { + await Notification.create({ + event: NotificationEventType.MentionedInDocument, + userId: recipient.id, + revisionId: event.modelId, + actorId: document.updatedBy.id, + teamId: document.teamId, + documentId: document.id, + }); + userIdsMentioned.push(recipient.id); + } + } + + const recipients = ( + await NotificationHelper.getDocumentNotificationRecipients( + document, + NotificationEventType.UpdateDocument, + document.lastModifiedById, + true + ) + ).filter((recipient) => !userIdsMentioned.includes(recipient.id)); + if (!recipients.length) { + return; + } + + for (const recipient of recipients) { + const notify = await this.shouldNotify(document, recipient); + + if (notify) { + await Notification.create({ + event: NotificationEventType.UpdateDocument, + userId: recipient.id, + revisionId: event.modelId, + actorId: document.updatedBy.id, + teamId: document.teamId, + documentId: document.id, + }); + } + } + } + + private shouldNotify = async ( + document: Document, + user: User + ): Promise => { + // Create only a single notification in a 6 hour window + const notification = await Notification.findOne({ + order: [["createdAt", "DESC"]], + where: { + userId: user.id, + documentId: document.id, + emailedAt: { + [Op.not]: null, + [Op.gte]: subHours(new Date(), 6), + }, + }, + }); + + if (notification) { + if (env.ENVIRONMENT === "development") { + Logger.info( + "processor", + `would have suppressed notification to ${user.id}, but not in development` + ); + } else { + Logger.info( + "processor", + `suppressing notification to ${user.id} as recently notified` + ); + return false; + } + } + + // If this recipient has viewed the document since the last update was made + // then we can avoid sending them a useless notification, yay. + const view = await View.findOne({ + where: { + userId: user.id, + documentId: document.id, + updatedAt: { + [Op.gt]: document.updatedAt, + }, + }, + }); + + if (view) { + Logger.info( + "processor", + `suppressing notification to ${user.id} because update viewed` + ); + return false; + } + + return true; + }; + + public get options() { + return { + priority: TaskPriority.Background, + }; + } +} diff --git a/server/types.ts b/server/types.ts index 22f402a60..2f0c77de5 100644 --- a/server/types.ts +++ b/server/types.ts @@ -76,7 +76,7 @@ export type AttachmentEvent = BaseEvent & modelId: string; data: { name: string; - source: string; + source?: "import"; }; } | { @@ -215,10 +215,15 @@ export type CollectionEvent = BaseEvent & | CollectionUserEvent | CollectionGroupEvent | { - name: - | "collections.create" - | "collections.update" - | "collections.delete"; + name: "collections.create"; + collectionId: string; + data: { + name: string; + source?: "import"; + }; + } + | { + name: "collections.update" | "collections.delete"; collectionId: string; data: { name: string; @@ -352,6 +357,14 @@ export type WebhookSubscriptionEvent = BaseEvent & { }; }; +export type NotificationEvent = BaseEvent & { + name: "notifications.create"; + modelId: string; + teamId: string; + userId: string; + documentId?: string; +}; + export type Event = | ApiKeyEvent | AttachmentEvent @@ -370,7 +383,8 @@ export type Event = | TeamEvent | UserEvent | ViewEvent - | WebhookSubscriptionEvent; + | WebhookSubscriptionEvent + | NotificationEvent; export type NotificationMetadata = { notificationId?: string; diff --git a/shared/types.ts b/shared/types.ts index 01ac23634..a0b2b75b3 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -165,7 +165,8 @@ export enum NotificationEventType { UpdateDocument = "documents.update", CreateCollection = "collections.create", CreateComment = "comments.create", - Mentioned = "comments.mentioned", + MentionedInDocument = "documents.mentioned", + MentionedInComment = "comments.mentioned", InviteAccepted = "emails.invite_accepted", Onboarding = "emails.onboarding", Features = "emails.features", @@ -191,7 +192,8 @@ export const NotificationEventDefaults = { [NotificationEventType.UpdateDocument]: true, [NotificationEventType.CreateCollection]: false, [NotificationEventType.CreateComment]: true, - [NotificationEventType.Mentioned]: true, + [NotificationEventType.MentionedInDocument]: true, + [NotificationEventType.MentionedInComment]: true, [NotificationEventType.InviteAccepted]: true, [NotificationEventType.Onboarding]: true, [NotificationEventType.Features]: true, diff --git a/yarn.lock b/yarn.lock index 3254b47c7..9f8ba5a4d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12675,12 +12675,7 @@ tslib@^1.8.1, tslib@^1.9.3: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.4.0, tslib@^2.4.1: - version "2.4.1" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.1.tgz#0d0bfbaac2880b91e22df0768e55be9753a5b17e" - integrity sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA== - -tslib@^2.5.0: +tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.4.0, tslib@^2.4.1, tslib@^2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.0.tgz#42bfed86f5787aeb41d031866c8f402429e0fddf" integrity sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==