feat: Document subscriptions (#3834)

Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
CuriousCorrelation
2022-08-26 12:17:13 +05:30
committed by GitHub
parent 864f585e5b
commit 24c71c38a5
36 changed files with 2594 additions and 165 deletions

View File

@@ -1,5 +1,5 @@
import DocumentNotificationEmail from "@server/emails/templates/DocumentNotificationEmail";
import { View, NotificationSetting } from "@server/models";
import { View, NotificationSetting, Subscription, Event } from "@server/models";
import {
buildDocument,
buildCollection,
@@ -108,9 +108,7 @@ describe("documents.publish", () => {
describe("revisions.create", () => {
test("should send a notification to other collaborators", async () => {
const document = await buildDocument();
const collaborator = await buildUser({
teamId: document.teamId,
});
const collaborator = await buildUser({ teamId: document.teamId });
document.collaboratorIds = [collaborator.id];
await document.save();
await NotificationSetting.create({
@@ -133,9 +131,7 @@ describe("revisions.create", () => {
test("should not send a notification if viewed since update", async () => {
const document = await buildDocument();
const collaborator = await buildUser({
teamId: document.teamId,
});
const collaborator = await buildUser({ teamId: document.teamId });
document.collaboratorIds = [collaborator.id];
await document.save();
await NotificationSetting.create({
@@ -181,4 +177,384 @@ describe("revisions.create", () => {
});
expect(DocumentNotificationEmail.schedule).not.toHaveBeenCalled();
});
test("should send a notification for subscriptions, even to collaborator", async () => {
const document = await buildDocument();
const collaborator = await buildUser({ teamId: document.teamId });
const subscriber = await buildUser({ teamId: document.teamId });
document.collaboratorIds = [collaborator.id, subscriber.id];
await document.save();
await NotificationSetting.create({
userId: collaborator.id,
teamId: collaborator.teamId,
event: "documents.update",
});
await Subscription.create({
userId: subscriber.id,
documentId: document.id,
event: "documents.update",
enabled: true,
});
const processor = new NotificationsProcessor();
await processor.perform({
name: "revisions.create",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: collaborator.id,
modelId: document.id,
ip,
});
expect(DocumentNotificationEmail.schedule).toHaveBeenCalled();
});
test("should create subscriptions for collaborator", async () => {
const document = await buildDocument();
const collaborator0 = await buildUser({ teamId: document.teamId });
const collaborator1 = await buildUser({ teamId: document.teamId });
const collaborator2 = await buildUser({ teamId: document.teamId });
document.collaboratorIds = [
collaborator0.id,
collaborator1.id,
collaborator2.id,
];
await document.save();
const processor = new NotificationsProcessor();
await processor.perform({
name: "revisions.create",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: collaborator0.id,
modelId: document.id,
ip,
});
const events = await Event.findAll();
// Should emit 3 `subscriptions.create` events.
expect(events.length).toEqual(3);
expect(events[0].name).toEqual("subscriptions.create");
expect(events[1].name).toEqual("subscriptions.create");
expect(events[2].name).toEqual("subscriptions.create");
// Each event should point to same document.
expect(events[0].documentId).toEqual(document.id);
expect(events[1].documentId).toEqual(document.id);
expect(events[2].documentId).toEqual(document.id);
// Events should mention correct `userId`.
expect(events[0].userId).toEqual(collaborator0.id);
expect(events[1].userId).toEqual(collaborator1.id);
expect(events[2].userId).toEqual(collaborator2.id);
// Should send email notification.
expect(DocumentNotificationEmail.schedule).toHaveBeenCalledTimes(3);
});
test("should not send multiple emails", async () => {
const document = await buildDocument();
const collaborator0 = await buildUser({ teamId: document.teamId });
const collaborator1 = await buildUser({ teamId: document.teamId });
const collaborator2 = await buildUser({ teamId: document.teamId });
document.collaboratorIds = [
collaborator0.id,
collaborator1.id,
collaborator2.id,
];
await document.save();
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,
});
// Those changes will also emit a `revisions.create` event.
await processor.perform({
name: "revisions.create",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: collaborator0.id,
modelId: document.id,
ip,
});
const events = await Event.findAll();
// Should emit 3 `subscriptions.create` events.
expect(events.length).toEqual(3);
expect(events[0].name).toEqual("subscriptions.create");
expect(events[1].name).toEqual("subscriptions.create");
expect(events[2].name).toEqual("subscriptions.create");
// Each event should point to same document.
expect(events[0].documentId).toEqual(document.id);
expect(events[1].documentId).toEqual(document.id);
expect(events[2].documentId).toEqual(document.id);
// Events should mention correct `userId`.
expect(events[0].userId).toEqual(collaborator0.id);
expect(events[1].userId).toEqual(collaborator1.id);
expect(events[2].userId).toEqual(collaborator2.id);
// This should send out 3 emails, one for each collaborator,
// and not 6, for both `documents.update` and `revisions.create`.
expect(DocumentNotificationEmail.schedule).toHaveBeenCalledTimes(3);
});
test("should not create subscriptions if previously unsubscribed", async () => {
const document = await buildDocument();
const collaborator0 = await buildUser({ teamId: document.teamId });
const collaborator1 = await buildUser({ teamId: document.teamId });
const collaborator2 = await buildUser({ teamId: document.teamId });
document.collaboratorIds = [
collaborator0.id,
collaborator1.id,
collaborator2.id,
];
await document.save();
// `collaborator2` created a subscription.
const subscription2 = await Subscription.create({
userId: collaborator2.id,
documentId: document.id,
event: "documents.update",
});
// `collaborator2` would no longer like to be notified.
await subscription2.destroy();
const processor = new NotificationsProcessor();
await processor.perform({
name: "revisions.create",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: collaborator0.id,
modelId: document.id,
ip,
});
const events = await Event.findAll();
// Should emit 2 `subscriptions.create` events.
expect(events.length).toEqual(2);
expect(events[0].name).toEqual("subscriptions.create");
expect(events[1].name).toEqual("subscriptions.create");
// Each event should point to same document.
expect(events[0].documentId).toEqual(document.id);
expect(events[1].documentId).toEqual(document.id);
// Events should mention correct `userId`.
expect(events[0].userId).toEqual(collaborator0.id);
expect(events[1].userId).toEqual(collaborator1.id);
expect(DocumentNotificationEmail.schedule).toHaveBeenCalledTimes(2);
});
test("should send a notification for subscriptions to non-collaborators", async () => {
const document = await buildDocument();
const collaborator = await buildUser({ teamId: document.teamId });
const subscriber = await buildUser({ teamId: document.teamId });
// `subscriber` hasn't collaborated on `document`.
document.collaboratorIds = [collaborator.id];
await document.save();
await NotificationSetting.create({
userId: collaborator.id,
teamId: collaborator.teamId,
event: "documents.update",
});
// `subscriber` subscribes to `document`'s changes.
// Specifically "documents.update" event.
await Subscription.create({
userId: subscriber.id,
documentId: document.id,
event: "documents.update",
enabled: true,
});
const processor = new NotificationsProcessor();
await processor.perform({
name: "revisions.create",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: collaborator.id,
modelId: document.id,
ip,
});
expect(DocumentNotificationEmail.schedule).toHaveBeenCalled();
});
test("should not send a notification for subscriptions to collaborators if unsubscribed", async () => {
const document = await buildDocument();
const collaborator = await buildUser({ teamId: document.teamId });
const subscriber = await buildUser({ teamId: document.teamId });
// `subscriber` has collaborated on `document`.
document.collaboratorIds = [collaborator.id, subscriber.id];
await document.save();
await NotificationSetting.create({
userId: collaborator.id,
teamId: collaborator.teamId,
event: "documents.update",
});
// `subscriber` subscribes to `document`'s changes.
// Specifically "documents.update" event.
const subscription = await Subscription.create({
userId: subscriber.id,
documentId: document.id,
event: "documents.update",
enabled: true,
});
subscription.destroy();
const processor = new NotificationsProcessor();
await processor.perform({
name: "revisions.create",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: collaborator.id,
modelId: document.id,
ip,
});
// Should send notification to `collaborator` and not `subscriber`.
expect(DocumentNotificationEmail.schedule).toHaveBeenCalledTimes(1);
});
test("should not send a notification for subscriptions to members outside of the team", async () => {
const document = await buildDocument();
const collaborator = await buildUser({ teamId: document.teamId });
// `subscriber` *does not* belong
// to `collaborator`'s team,
const subscriber = await buildUser();
// `subscriber` hasn't collaborated on `document`.
document.collaboratorIds = [collaborator.id];
await document.save();
await NotificationSetting.create({
userId: collaborator.id,
teamId: collaborator.teamId,
event: "documents.update",
});
// `subscriber` subscribes to `document`'s changes.
// Specifically "documents.update" event.
// Not sure how they got hold of this document,
// but let's just pretend they did!
await Subscription.create({
userId: subscriber.id,
documentId: document.id,
event: "documents.update",
enabled: true,
});
const processor = new NotificationsProcessor();
await processor.perform({
name: "revisions.create",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: collaborator.id,
modelId: document.id,
ip,
});
// Should send notification to `collaborator` and not `subscriber`.
expect(DocumentNotificationEmail.schedule).toHaveBeenCalledTimes(1);
});
test("should not send a notification if viewed since update", async () => {
const document = await buildDocument();
const collaborator = await buildUser({ teamId: document.teamId });
document.collaboratorIds = [collaborator.id];
await document.save();
await NotificationSetting.create({
userId: collaborator.id,
teamId: collaborator.teamId,
event: "documents.update",
});
await View.touch(document.id, collaborator.id, true);
const processor = new NotificationsProcessor();
await processor.perform({
name: "revisions.create",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: collaborator.id,
modelId: document.id,
ip,
});
expect(DocumentNotificationEmail.schedule).not.toHaveBeenCalled();
});
test("should not send a notification to last editor", async () => {
const user = await buildUser();
const document = await buildDocument({
teamId: user.teamId,
lastModifiedById: user.id,
});
await NotificationSetting.create({
userId: user.id,
teamId: user.teamId,
event: "documents.update",
});
const processor = new NotificationsProcessor();
await processor.perform({
name: "revisions.create",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
modelId: document.id,
ip,
});
expect(DocumentNotificationEmail.schedule).not.toHaveBeenCalled();
});
});

View File

@@ -1,4 +1,7 @@
import { uniqBy } from "lodash";
import { Op } from "sequelize";
import subscriptionCreator from "@server/commands/subscriptionCreator";
import { sequelize } from "@server/database/sequelize";
import CollectionNotificationEmail from "@server/emails/templates/CollectionNotificationEmail";
import DocumentNotificationEmail from "@server/emails/templates/DocumentNotificationEmail";
import Logger from "@server/logging/Logger";
@@ -9,12 +12,14 @@ import {
Collection,
User,
NotificationSetting,
Subscription,
} from "@server/models";
import { can } from "@server/policies";
import {
DocumentEvent,
CollectionEvent,
RevisionEvent,
Event,
DocumentEvent,
} from "@server/types";
import BaseProcessor from "./BaseProcessor";
@@ -44,141 +49,217 @@ export default class NotificationsProcessor extends BaseProcessor {
if (event.data?.source === "import") {
return;
}
const [collection, document, team] = await Promise.all([
Collection.findByPk(event.collectionId),
Document.findByPk(event.documentId),
Team.findByPk(event.teamId),
]);
if (!document || !team || !collection) {
return;
}
const notificationSettings = await NotificationSetting.findAll({
where: {
userId: {
[Op.ne]: document.lastModifiedById,
},
teamId: document.teamId,
event:
event.name === "documents.publish"
? "documents.publish"
: "documents.update",
},
include: [
{
model: User,
required: true,
as: "user",
},
],
});
const eventName =
event.name === "documents.publish" ? "published" : "updated";
for (const setting of notificationSettings) {
// Suppress notifications for suspended users
if (setting.user.isSuspended) {
continue;
await this.createDocumentSubscriptions(document, event);
const recipients = await this.getDocumentNotificationRecipients(
document,
event.name === "documents.publish"
? "documents.publish"
: "documents.update"
);
for (const recipient of recipients) {
const notify = await this.shouldNotify(document, recipient.user);
if (notify) {
await DocumentNotificationEmail.schedule({
to: recipient.user.email,
eventName:
event.name === "documents.publish" ? "published" : "updated",
documentId: document.id,
teamUrl: team.url,
actorName: document.updatedBy.name,
collectionName: collection.name,
unsubscribeUrl: recipient.unsubscribeUrl,
});
}
// For document updates we only want to send notifications if
// the document has been edited by the user with this notification setting
// This could be replaced with ability to "follow" in the future
if (
eventName === "updated" &&
!document.collaboratorIds.includes(setting.userId)
) {
continue;
}
// Check the user has access to the collection this document is in. Just
// because they were a collaborator once doesn't mean they still are.
const collectionIds = await setting.user.collectionIds();
if (!collectionIds.includes(document.collectionId)) {
continue;
}
// If this user 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: setting.userId,
documentId: event.documentId,
updatedAt: {
[Op.gt]: document.updatedAt,
},
},
});
if (view) {
Logger.info(
"processor",
`suppressing notification to ${setting.userId} because update viewed`
);
continue;
}
if (!setting.user.email) {
continue;
}
await DocumentNotificationEmail.schedule({
to: setting.user.email,
eventName,
documentId: document.id,
teamUrl: team.url,
actorName: document.updatedBy.name,
collectionName: collection.name,
unsubscribeUrl: setting.unsubscribeUrl,
});
}
}
async collectionCreated(event: CollectionEvent) {
const collection = await Collection.findByPk(event.collectionId, {
include: [
{
model: User,
required: true,
as: "user",
},
],
const collection = await Collection.scope("withUser").findByPk(
event.collectionId
);
if (!collection || !collection.permission) {
return;
}
const recipients = await this.getCollectionNotificationRecipients(
collection,
event.name
);
for (const recipient of recipients) {
// Suppress notifications for suspended users
if (recipient.user.isSuspended || !recipient.user.email) {
continue;
}
await CollectionNotificationEmail.schedule({
to: recipient.user.email,
eventName: "created",
collectionId: collection.id,
unsubscribeUrl: recipient.unsubscribeUrl,
});
}
}
/**
* 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) {
if (user && can(user, "subscribe", document)) {
await subscriptionCreator({
user,
documentId: document.id,
event: "documents.update",
resubscribe: false,
transaction,
ip: event.ip,
});
}
}
});
if (!collection) {
return;
}
if (!collection.permission) {
return;
}
const notificationSettings = await NotificationSetting.findAll({
};
/**
* 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: event.name,
event: eventName,
},
include: [
{
model: User,
required: true,
as: "user",
},
],
});
for (const setting of notificationSettings) {
// Suppress notifications for suspended users
if (setting.user.isSuspended || !setting.user.email) {
continue;
}
// Ensure we only have one recipient per user as a safety measure
return uniqBy(recipients, "userId");
};
await CollectionNotificationEmail.schedule({
to: setting.user.email,
eventName: "created",
collectionId: collection.id,
unsubscribeUrl: setting.unsubscribeUrl,
/**
* 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;
}
// 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;
};
}

View File

@@ -97,6 +97,8 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
case "api_keys.delete":
case "attachments.create":
case "attachments.delete":
case "subscriptions.create":
case "subscriptions.delete":
case "authenticationProviders.update":
// Ignored
return;