From 9c9ceef8ee0cf94d658dc19d393703b8b362756f Mon Sep 17 00:00:00 2001
From: Tom Moor
Date: Sat, 8 Apr 2023 09:22:49 -0400
Subject: [PATCH] Notifications refactor (#5151
* Ongoing
* refactor
* test
* Add cleanup task
* refactor
---
app/scenes/Settings/Notifications.tsx | 2 +-
.../server/tasks/DeliverWebhookTask.ts | 1 +
server/commands/subscriptionCreator.ts | 33 +-
.../templates/CollectionCreatedEmail.tsx | 38 +-
.../emails/templates/CommentCreatedEmail.tsx | 79 +++-
.../templates/CommentMentionedEmail.tsx | 69 +++-
.../templates/DocumentMentionedEmail.tsx | 30 +-
.../DocumentPublishedOrUpdatedEmail.tsx | 82 ++++-
.../emails/templates/ExportFailureEmail.tsx | 7 +-
.../emails/templates/ExportSuccessEmail.tsx | 7 +-
.../emails/templates/InviteAcceptedEmail.tsx | 7 +-
...0403120315-add-comment-to-notifications.js | 38 ++
server/models/Notification.ts | 80 +++-
server/queues/processors/EmailsProcessor.ts | 51 +++
.../processors/NotificationsProcessor.ts | 346 ++----------------
.../tasks/CleanupOldNotificationsTask.ts | 52 +++
.../CollectionCreatedNotificationsTask.ts | 44 +++
.../tasks/CommentCreatedNotificationTask.ts | 141 -------
.../tasks/CommentCreatedNotificationsTask.ts | 91 +++++
.../tasks/CommentUpdatedNotificationTask.ts | 91 -----
.../tasks/CommentUpdatedNotificationsTask.ts | 57 +++
...DocumentPublishedNotificationsTask.test.ts | 137 +++++++
.../DocumentPublishedNotificationsTask.ts | 72 ++++
.../RevisionCreatedNotificationsTask.test.ts} | 284 +++-----------
.../tasks/RevisionCreatedNotificationsTask.ts | 145 ++++++++
server/types.ts | 26 +-
shared/types.ts | 6 +-
yarn.lock | 7 +-
28 files changed, 1122 insertions(+), 901 deletions(-)
create mode 100644 server/migrations/20230403120315-add-comment-to-notifications.js
create mode 100644 server/queues/processors/EmailsProcessor.ts
create mode 100644 server/queues/tasks/CleanupOldNotificationsTask.ts
create mode 100644 server/queues/tasks/CollectionCreatedNotificationsTask.ts
delete mode 100644 server/queues/tasks/CommentCreatedNotificationTask.ts
create mode 100644 server/queues/tasks/CommentCreatedNotificationsTask.ts
delete mode 100644 server/queues/tasks/CommentUpdatedNotificationTask.ts
create mode 100644 server/queues/tasks/CommentUpdatedNotificationsTask.ts
create mode 100644 server/queues/tasks/DocumentPublishedNotificationsTask.test.ts
create mode 100644 server/queues/tasks/DocumentPublishedNotificationsTask.ts
rename server/queues/{processors/NotificationsProcessor.test.ts => tasks/RevisionCreatedNotificationsTask.test.ts} (64%)
create mode 100644 server/queues/tasks/RevisionCreatedNotificationsTask.ts
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.