Comment notification emails (#4978)

* Comment notification emails

* fix links
fix threading in email inboxes
from is now commenter name

* fix

* refactor

* fix async filter
This commit is contained in:
Tom Moor
2023-03-05 11:01:56 -05:00
committed by GitHub
parent 4ff0fdfb4f
commit 760355302c
10 changed files with 599 additions and 236 deletions

View File

@@ -1,6 +1,6 @@
import { subHours } from "date-fns";
import { uniqBy } from "lodash";
import { Op } from "sequelize";
import { Minute } from "@shared/utils/time";
import subscriptionCreator from "@server/commands/subscriptionCreator";
import { sequelize } from "@server/database/sequelize";
import CollectionNotificationEmail from "@server/emails/templates/CollectionNotificationEmail";
@@ -8,23 +8,24 @@ import DocumentNotificationEmail from "@server/emails/templates/DocumentNotifica
import env from "@server/env";
import Logger from "@server/logging/Logger";
import {
View,
Document,
Team,
Collection,
User,
NotificationSetting,
Subscription,
Notification,
Revision,
User,
View,
} from "@server/models";
import DocumentHelper from "@server/models/helpers/DocumentHelper";
import NotificationHelper from "@server/models/helpers/NotificationHelper";
import {
CollectionEvent,
RevisionEvent,
Event,
DocumentEvent,
CommentEvent,
} from "@server/types";
import CommentCreatedNotificationTask from "../tasks/CommentCreatedNotificationTask";
import BaseProcessor from "./BaseProcessor";
export default class NotificationsProcessor extends BaseProcessor {
@@ -32,6 +33,7 @@ export default class NotificationsProcessor extends BaseProcessor {
"documents.publish",
"revisions.create",
"collections.create",
"comments.create",
];
async perform(event: Event) {
@@ -42,11 +44,18 @@ export default class NotificationsProcessor extends BaseProcessor {
return this.revisionCreated(event);
case "collections.create":
return this.collectionCreated(event);
case "comments.create":
return this.commentCreated(event);
default:
}
}
async commentCreated(event: CommentEvent) {
await CommentCreatedNotificationTask.schedule(event, {
delay: Minute,
});
}
async documentPublished(event: DocumentEvent) {
// never send notifications when batch importing documents
if (
@@ -69,9 +78,10 @@ export default class NotificationsProcessor extends BaseProcessor {
await this.createDocumentSubscriptions(document, event);
const recipients = await this.getDocumentNotificationRecipients(
const recipients = await NotificationHelper.getDocumentNotificationRecipients(
document,
"documents.publish"
"documents.publish",
document.lastModifiedById
);
for (const recipient of recipients) {
@@ -115,27 +125,26 @@ export default class NotificationsProcessor extends BaseProcessor {
await this.createDocumentSubscriptions(document, event);
const recipients = await this.getDocumentNotificationRecipients(
const recipients = await NotificationHelper.getDocumentNotificationRecipients(
document,
"documents.update"
"documents.update",
document.lastModifiedById
);
if (!recipients.length) {
return;
}
// generate the diff html for the email
const before = await revision.previous();
let content = await DocumentHelper.toEmailDiff(before, revision, {
const content = await DocumentHelper.toEmailDiff(before, revision, {
includeTitle: false,
centered: false,
signedUrls: 86400 * 4,
});
if (!content) {
return;
}
content = await DocumentHelper.attachmentsToSignedUrls(
content,
event.teamId,
86400 * 4
);
for (const recipient of recipients) {
const notify = await this.shouldNotify(document, recipient.user);
@@ -174,7 +183,7 @@ export default class NotificationsProcessor extends BaseProcessor {
return;
}
const recipients = await this.getCollectionNotificationRecipients(
const recipients = await NotificationHelper.getCollectionNotificationRecipients(
collection,
event.name
);
@@ -194,128 +203,10 @@ export default class NotificationsProcessor extends BaseProcessor {
}
}
/**
* 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<void> => {
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,
});
}
});
};
/**
* Get the recipients of a notification for a collection event.
*
* @param collection The collection to get recipients for
* @param eventName The event name
* @returns A list of recipients
*/
private getCollectionNotificationRecipients = async (
collection: Collection,
eventName: string
): Promise<NotificationSetting[]> => {
// First find all the users that have notifications enabled for this event
// type at all and aren't the one that performed the action.
const recipients = await NotificationSetting.scope("withUser").findAll({
where: {
userId: {
[Op.ne]: collection.createdById,
},
teamId: collection.teamId,
event: eventName,
},
});
// Ensure we only have one recipient per user as a safety measure
return uniqBy(recipients, "userId");
};
/**
* Get the recipients of a notification for a document event.
*
* @param document The document to get recipients for
* @param eventName The event name
* @returns A list of recipients
*/
private getDocumentNotificationRecipients = async (
document: Document,
eventName: string
): Promise<NotificationSetting[]> => {
// First find all the users that have notifications enabled for this event
// type at all and aren't the one that performed the action.
let recipients = await NotificationSetting.scope("withUser").findAll({
where: {
userId: {
[Op.ne]: document.lastModifiedById,
},
teamId: document.teamId,
event: eventName,
},
});
// If the event is a revision creation we can filter further to only those
// that have a subscription to the document…
if (eventName === "documents.update") {
const subscriptions = await Subscription.findAll({
attributes: ["userId"],
where: {
userId: recipients.map((recipient) => recipient.user.id),
documentId: document.id,
event: eventName,
},
});
const subscribedUserIds = subscriptions.map(
(subscription) => subscription.userId
);
recipients = recipients.filter((recipient) =>
subscribedUserIds.includes(recipient.user.id)
);
}
// Ensure we only have one recipient per user as a safety measure
return uniqBy(recipients, "userId");
};
private shouldNotify = async (
document: Document,
user: User
): Promise<boolean> => {
// Suppress notifications for suspended and users with no email address
if (user.isSuspended || !user.email) {
return false;
}
// Check the recipient has access to the collection this document is in. Just
// because they are subscribed doesn't meant they still have access to read
// the document.
const collectionIds = await user.collectionIds();
if (!collectionIds.includes(document.collectionId)) {
return false;
}
// Deliver only a single notification in a 12 hour window
const notification = await Notification.findOne({
order: [["createdAt", "DESC"]],
@@ -366,4 +257,33 @@ export default class NotificationsProcessor extends BaseProcessor {
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<void> => {
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,
});
}
});
};
}