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}
-
+
Open Collection
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==