diff --git a/app/actions/definitions/documents.tsx b/app/actions/definitions/documents.tsx
index 1092dbe49..e3d74a124 100644
--- a/app/actions/definitions/documents.tsx
+++ b/app/actions/definitions/documents.tsx
@@ -11,6 +11,8 @@ import {
ImportIcon,
PinIcon,
SearchIcon,
+ UnsubscribeIcon,
+ SubscribeIcon,
MoveIcon,
TrashIcon,
CrossIcon,
@@ -115,6 +117,68 @@ export const unstarDocument = createAction({
},
});
+export const subscribeDocument = createAction({
+ name: ({ t }) => t("Subscribe"),
+ section: DocumentSection,
+ icon: ,
+ visible: ({ activeDocumentId, stores }) => {
+ if (!activeDocumentId) {
+ return false;
+ }
+
+ const document = stores.documents.get(activeDocumentId);
+
+ return (
+ !document?.isSubscribed &&
+ stores.policies.abilities(activeDocumentId).subscribe
+ );
+ },
+ perform: ({ activeDocumentId, stores, t }) => {
+ if (!activeDocumentId) {
+ return;
+ }
+
+ const document = stores.documents.get(activeDocumentId);
+
+ document?.subscribe();
+
+ stores.toasts.showToast(t("Subscribed to document notifications"), {
+ type: "success",
+ });
+ },
+});
+
+export const unsubscribeDocument = createAction({
+ name: ({ t }) => t("Unsubscribe"),
+ section: DocumentSection,
+ icon: ,
+ visible: ({ activeDocumentId, stores }) => {
+ if (!activeDocumentId) {
+ return false;
+ }
+
+ const document = stores.documents.get(activeDocumentId);
+
+ return (
+ !!document?.isSubscribed &&
+ stores.policies.abilities(activeDocumentId).unsubscribe
+ );
+ },
+ perform: ({ activeDocumentId, stores, currentUserId, t }) => {
+ if (!activeDocumentId || !currentUserId) {
+ return;
+ }
+
+ const document = stores.documents.get(activeDocumentId);
+
+ document?.unsubscribe(currentUserId);
+
+ stores.toasts.showToast(t("Unsubscribed from document notifications"), {
+ type: "success",
+ });
+ },
+});
+
export const downloadDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Download") : t("Download document"),
@@ -471,6 +535,8 @@ export const rootDocumentActions = [
downloadDocument,
starDocument,
unstarDocument,
+ subscribeDocument,
+ unsubscribeDocument,
duplicateDocument,
moveDocument,
permanentlyDeleteDocument,
diff --git a/app/menus/DocumentMenu.tsx b/app/menus/DocumentMenu.tsx
index 2b84faa74..7afb07add 100644
--- a/app/menus/DocumentMenu.tsx
+++ b/app/menus/DocumentMenu.tsx
@@ -27,6 +27,8 @@ import { actionToMenuItem } from "~/actions";
import {
pinDocument,
createTemplate,
+ subscribeDocument,
+ unsubscribeDocument,
moveDocument,
deleteDocument,
permanentlyDeleteDocument,
@@ -250,9 +252,10 @@ function DocumentMenu({
...restoreItems,
],
},
- actionToMenuItem(unstarDocument, context),
actionToMenuItem(starDocument, context),
- actionToMenuItem(pinDocument, context),
+ actionToMenuItem(unstarDocument, context),
+ actionToMenuItem(subscribeDocument, context),
+ actionToMenuItem(unsubscribeDocument, context),
{
type: "separator",
},
@@ -284,6 +287,10 @@ function DocumentMenu({
},
actionToMenuItem(archiveDocument, context),
actionToMenuItem(moveDocument, context),
+ actionToMenuItem(pinDocument, context),
+ {
+ type: "separator",
+ },
actionToMenuItem(deleteDocument, context),
actionToMenuItem(permanentlyDeleteDocument, context),
{
diff --git a/app/models/Document.ts b/app/models/Document.ts
index f33608088..60cddc471 100644
--- a/app/models/Document.ts
+++ b/app/models/Document.ts
@@ -155,6 +155,19 @@ export default class Document extends ParanoidModel {
);
}
+ /**
+ * Returns whether there is a subscription for this document in the store.
+ * Does not consider remote state.
+ *
+ * @returns True if there is a subscription, false otherwise.
+ */
+ @computed
+ get isSubscribed(): boolean {
+ return !!this.store.rootStore.subscriptions.orderedData.find(
+ (subscription) => subscription.documentId === this.id
+ );
+ }
+
@computed
get isArchived(): boolean {
return !!this.archivedAt;
@@ -255,15 +268,15 @@ export default class Document extends ParanoidModel {
};
@action
- pin = async (collectionId?: string) => {
- await this.store.rootStore.pins.create({
+ pin = (collectionId?: string) => {
+ return this.store.rootStore.pins.create({
documentId: this.id,
...(collectionId ? { collectionId } : {}),
});
};
@action
- unpin = async (collectionId?: string) => {
+ unpin = (collectionId?: string) => {
const pin = this.store.rootStore.pins.orderedData.find(
(pin) =>
pin.documentId === this.id &&
@@ -271,19 +284,39 @@ export default class Document extends ParanoidModel {
(!collectionId && !pin.collectionId))
);
- await pin?.delete();
+ return pin?.delete();
};
@action
- star = async () => {
+ star = () => {
return this.store.star(this);
};
@action
- unstar = async () => {
+ unstar = () => {
return this.store.unstar(this);
};
+ /**
+ * Subscribes the current user to this document.
+ *
+ * @returns A promise that resolves when the subscription is created.
+ */
+ @action
+ subscribe = () => {
+ return this.store.subscribe(this);
+ };
+
+ /**
+ * Unsubscribes the current user to this document.
+ *
+ * @returns A promise that resolves when the subscription is destroyed.
+ */
+ @action
+ unsubscribe = (userId: string) => {
+ return this.store.unsubscribe(userId, this);
+ };
+
@action
view = () => {
// we don't record views for documents in the trash
@@ -304,7 +337,7 @@ export default class Document extends ParanoidModel {
};
@action
- templatize = async () => {
+ templatize = () => {
return this.store.templatize(this.id);
};
diff --git a/app/models/Subscription.ts b/app/models/Subscription.ts
new file mode 100644
index 000000000..ca0bcdd38
--- /dev/null
+++ b/app/models/Subscription.ts
@@ -0,0 +1,29 @@
+import { observable } from "mobx";
+import BaseModel from "./BaseModel";
+import Field from "./decorators/Field";
+
+/**
+ * A subscription represents a request for a user to receive notifications for
+ * a document.
+ */
+class Subscription extends BaseModel {
+ @Field
+ @observable
+ id: string;
+
+ /** The user subscribing */
+ userId: string;
+
+ /** The document being subscribed to */
+ documentId: string;
+
+ /** The event being subscribed to */
+ @Field
+ @observable
+ event: string;
+
+ createdAt: string;
+ updatedAt: string;
+}
+
+export default Subscription;
diff --git a/app/scenes/Document/components/DataLoader.tsx b/app/scenes/Document/components/DataLoader.tsx
index 3924274bd..83a801b85 100644
--- a/app/scenes/Document/components/DataLoader.tsx
+++ b/app/scenes/Document/components/DataLoader.tsx
@@ -8,6 +8,7 @@ import ErrorOffline from "~/scenes/ErrorOffline";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { NavigationNode } from "~/types";
+import Logger from "~/utils/Logger";
import { NotFoundError, OfflineError } from "~/utils/errors";
import history from "~/utils/history";
import { matchDocumentEdit } from "~/utils/routeHelpers";
@@ -41,7 +42,7 @@ type Props = RouteComponentProps & {
};
function DataLoader({ match, children }: Props) {
- const { ui, shares, documents, auth, revisions } = useStores();
+ const { ui, shares, documents, auth, revisions, subscriptions } = useStores();
const { team } = auth;
const [error, setError] = React.useState(null);
const { revisionId, shareId, documentSlug } = match.params;
@@ -86,6 +87,22 @@ function DataLoader({ match, children }: Props) {
fetchRevision();
}, [revisions, revisionId]);
+ React.useEffect(() => {
+ async function fetchSubscription() {
+ if (document?.id) {
+ try {
+ await subscriptions.fetchPage({
+ documentId: document.id,
+ event: "documents.update",
+ });
+ } catch (err) {
+ Logger.error("Failed to fetch subscriptions", err);
+ }
+ }
+ }
+ fetchSubscription();
+ }, [document?.id, subscriptions]);
+
const onCreateLink = React.useCallback(
async (title: string) => {
if (!document) {
diff --git a/app/stores/DocumentsStore.ts b/app/stores/DocumentsStore.ts
index 01f70fe60..333d617b0 100644
--- a/app/stores/DocumentsStore.ts
+++ b/app/stores/DocumentsStore.ts
@@ -740,20 +740,37 @@ export default class DocumentsStore extends BaseStore {
}
};
- star = async (document: Document) => {
- await this.rootStore.stars.create({
+ star = (document: Document) => {
+ return this.rootStore.stars.create({
documentId: document.id,
});
};
- unstar = async (document: Document) => {
+ unstar = (document: Document) => {
const star = this.rootStore.stars.orderedData.find(
(star) => star.documentId === document.id
);
- await star?.delete();
+ return star?.delete();
};
- getByUrl = (url = ""): Document | null | undefined => {
+ subscribe = (document: Document) => {
+ return this.rootStore.subscriptions.create({
+ documentId: document.id,
+ event: "documents.update",
+ });
+ };
+
+ unsubscribe = (userId: string, document: Document) => {
+ const subscription = this.rootStore.subscriptions.orderedData.find(
+ (subscription) =>
+ subscription.documentId === document.id &&
+ subscription.userId === userId
+ );
+
+ return subscription?.delete();
+ };
+
+ getByUrl = (url = ""): Document | undefined => {
return find(this.orderedData, (doc) => url.endsWith(doc.urlId));
};
diff --git a/app/stores/RootStore.ts b/app/stores/RootStore.ts
index 88f8ac1f4..1bc19f254 100644
--- a/app/stores/RootStore.ts
+++ b/app/stores/RootStore.ts
@@ -18,6 +18,7 @@ import RevisionsStore from "./RevisionsStore";
import SearchesStore from "./SearchesStore";
import SharesStore from "./SharesStore";
import StarsStore from "./StarsStore";
+import SubscriptionsStore from "./SubscriptionsStore";
import ToastsStore from "./ToastsStore";
import UiStore from "./UiStore";
import UsersStore from "./UsersStore";
@@ -45,6 +46,7 @@ export default class RootStore {
shares: SharesStore;
ui: UiStore;
stars: StarsStore;
+ subscriptions: SubscriptionsStore;
users: UsersStore;
views: ViewsStore;
toasts: ToastsStore;
@@ -72,6 +74,7 @@ export default class RootStore {
this.searches = new SearchesStore(this);
this.shares = new SharesStore(this);
this.stars = new StarsStore(this);
+ this.subscriptions = new SubscriptionsStore(this);
this.ui = new UiStore();
this.users = new UsersStore(this);
this.views = new ViewsStore(this);
@@ -99,6 +102,7 @@ export default class RootStore {
this.searches.clear();
this.shares.clear();
this.stars.clear();
+ this.subscriptions.clear();
this.fileOperations.clear();
// this.ui omitted to keep ui settings between sessions
this.users.clear();
diff --git a/app/stores/SubscriptionsStore.ts b/app/stores/SubscriptionsStore.ts
new file mode 100644
index 000000000..e2a2b4c04
--- /dev/null
+++ b/app/stores/SubscriptionsStore.ts
@@ -0,0 +1,11 @@
+import Subscription from "~/models/Subscription";
+import BaseStore, { RPCAction } from "./BaseStore";
+import RootStore from "./RootStore";
+
+export default class SubscriptionsStore extends BaseStore {
+ actions = [RPCAction.List, RPCAction.Create, RPCAction.Delete];
+
+ constructor(rootStore: RootStore) {
+ super(rootStore, Subscription);
+ }
+}
diff --git a/package.json b/package.json
index fd8330169..82260c45c 100644
--- a/package.json
+++ b/package.json
@@ -136,7 +136,7 @@
"natural-sort": "^1.0.0",
"node-fetch": "2.6.7",
"nodemailer": "^6.6.1",
- "outline-icons": "^1.43.1",
+ "outline-icons": "^1.44.0",
"oy-vey": "^0.11.2",
"passport": "^0.6.0",
"passport-google-oauth2": "^0.2.0",
diff --git a/server/commands/subscriptionCreator.test.ts b/server/commands/subscriptionCreator.test.ts
new file mode 100644
index 000000000..c5871a078
--- /dev/null
+++ b/server/commands/subscriptionCreator.test.ts
@@ -0,0 +1,275 @@
+import { sequelize } from "@server/database/sequelize";
+import { Subscription, Event } from "@server/models";
+import { buildDocument, buildUser } from "@server/test/factories";
+import { getTestDatabase } from "@server/test/support";
+import subscriptionCreator from "./subscriptionCreator";
+import subscriptionDestroyer from "./subscriptionDestroyer";
+
+const db = getTestDatabase();
+
+beforeEach(db.flush);
+afterAll(db.disconnect);
+
+describe("subscriptionCreator", () => {
+ const ip = "127.0.0.1";
+ const subscribedEvent = "documents.update";
+
+ it("should create a subscription", async () => {
+ const user = await buildUser();
+
+ const document = await buildDocument({
+ userId: user.id,
+ teamId: user.teamId,
+ });
+
+ const subscription = await sequelize.transaction(async (transaction) =>
+ subscriptionCreator({
+ user,
+ documentId: document.id,
+ event: subscribedEvent,
+ ip,
+ transaction,
+ })
+ );
+
+ const event = await Event.findOne();
+
+ expect(subscription.documentId).toEqual(document.id);
+ expect(subscription.userId).toEqual(user.id);
+ expect(event?.name).toEqual("subscriptions.create");
+ expect(event?.modelId).toEqual(subscription.id);
+ expect(event?.actorId).toEqual(subscription.userId);
+ expect(event?.userId).toEqual(subscription.userId);
+ expect(event?.documentId).toEqual(subscription.documentId);
+ });
+
+ it("should not create another subscription if one already exists", async () => {
+ const user = await buildUser();
+
+ const document = await buildDocument({
+ userId: user.id,
+ teamId: user.teamId,
+ });
+
+ const subscription0 = await Subscription.create({
+ userId: user.id,
+ documentId: document.id,
+ event: subscribedEvent,
+ });
+
+ const subscription1 = await sequelize.transaction(async (transaction) =>
+ subscriptionCreator({
+ user,
+ documentId: document.id,
+ event: subscribedEvent,
+ ip,
+ transaction,
+ })
+ );
+
+ expect(subscription0.event).toEqual(subscribedEvent);
+ expect(subscription1.event).toEqual(subscribedEvent);
+
+ expect(subscription0.userId).toEqual(user.id);
+ expect(subscription1.userId).toEqual(user.id);
+
+ // Primary concern
+ expect(subscription0.id).toEqual(subscription1.id);
+
+ // Edge cases
+ expect(subscription0.documentId).toEqual(document.id);
+ expect(subscription1.documentId).toEqual(document.id);
+
+ expect(subscription0.userId).toEqual(subscription1.userId);
+ expect(subscription0.documentId).toEqual(subscription1.documentId);
+ });
+
+ it("should enable subscription by overriding one that exists in disabled state", async () => {
+ const user = await buildUser();
+
+ const document = await buildDocument({
+ userId: user.id,
+ teamId: user.teamId,
+ });
+
+ const subscription0 = await sequelize.transaction(async (transaction) =>
+ subscriptionCreator({
+ user,
+ documentId: document.id,
+ event: subscribedEvent,
+ ip,
+ transaction,
+ })
+ );
+
+ await sequelize.transaction(async (transaction) =>
+ subscriptionDestroyer({
+ user,
+ subscription: subscription0,
+ ip,
+ transaction,
+ })
+ );
+
+ expect(subscription0.id).toBeDefined();
+ expect(subscription0.userId).toEqual(user.id);
+ expect(subscription0.documentId).toEqual(document.id);
+ expect(subscription0.deletedAt).toBeDefined();
+
+ const subscription1 = await sequelize.transaction(async (transaction) =>
+ subscriptionCreator({
+ user,
+ documentId: document.id,
+ event: subscribedEvent,
+ ip,
+ transaction,
+ })
+ );
+
+ const events = await Event.count();
+
+ // 3 events. 1 create, 1 destroy and 1 re-create.
+ expect(events).toEqual(3);
+
+ expect(subscription0.id).toEqual(subscription1.id);
+ expect(subscription0.documentId).toEqual(document.id);
+ expect(subscription0.userId).toEqual(user.id);
+ expect(subscription1.documentId).toEqual(document.id);
+ expect(subscription1.userId).toEqual(user.id);
+ expect(subscription0.id).toEqual(subscription1.id);
+ expect(subscription0.userId).toEqual(subscription1.userId);
+ expect(subscription0.documentId).toEqual(subscription1.documentId);
+ });
+
+ it("should fetch already enabled subscription on create request", async () => {
+ const user = await buildUser();
+
+ const document = await buildDocument({
+ userId: user.id,
+ teamId: user.teamId,
+ });
+
+ const subscription0 = await sequelize.transaction(async (transaction) =>
+ subscriptionCreator({
+ user,
+ documentId: document.id,
+ event: subscribedEvent,
+ ip,
+ transaction,
+ })
+ );
+
+ const subscription1 = await sequelize.transaction(async (transaction) =>
+ subscriptionCreator({
+ user,
+ documentId: document.id,
+ event: subscribedEvent,
+ ip,
+ transaction,
+ })
+ );
+
+ // Should emit 1 event instead of 2.
+ const events = await Event.count();
+ expect(events).toEqual(1);
+
+ expect(subscription0.documentId).toEqual(document.id);
+ expect(subscription0.userId).toEqual(user.id);
+ expect(subscription1.documentId).toEqual(document.id);
+ expect(subscription1.userId).toEqual(user.id);
+ expect(subscription0.id).toEqual(subscription1.id);
+ expect(subscription0.userId).toEqual(subscription1.userId);
+ expect(subscription0.documentId).toEqual(subscription1.documentId);
+ });
+
+ it("should emit event when re-creating subscription", async () => {
+ const user = await buildUser();
+
+ const document = await buildDocument({
+ userId: user.id,
+ teamId: user.teamId,
+ });
+
+ const subscription0 = await sequelize.transaction(async (transaction) =>
+ subscriptionCreator({
+ user,
+ documentId: document.id,
+ event: subscribedEvent,
+ ip,
+ transaction,
+ })
+ );
+
+ await sequelize.transaction(async (transaction) =>
+ subscriptionDestroyer({
+ user,
+ subscription: subscription0,
+ ip,
+ transaction,
+ })
+ );
+
+ expect(subscription0.id).toBeDefined();
+ expect(subscription0.userId).toEqual(user.id);
+ expect(subscription0.documentId).toEqual(document.id);
+ expect(subscription0.deletedAt).toBeDefined();
+
+ const subscription1 = await sequelize.transaction(async (transaction) =>
+ subscriptionCreator({
+ user,
+ documentId: document.id,
+ event: subscribedEvent,
+ ip,
+ transaction,
+ })
+ );
+
+ // Should emit 3 events.
+ // 2 create, 1 destroy.
+ const events = await Event.findAll();
+ expect(events.length).toEqual(3);
+
+ expect(events[0].name).toEqual("subscriptions.create");
+ expect(events[0].documentId).toEqual(document.id);
+ expect(events[1].name).toEqual("subscriptions.delete");
+ expect(events[1].documentId).toEqual(document.id);
+ expect(events[2].name).toEqual("subscriptions.create");
+ expect(events[2].documentId).toEqual(document.id);
+
+ expect(subscription0.documentId).toEqual(document.id);
+ expect(subscription0.userId).toEqual(user.id);
+ expect(subscription1.documentId).toEqual(document.id);
+ expect(subscription1.userId).toEqual(user.id);
+ expect(subscription0.id).toEqual(subscription1.id);
+ expect(subscription0.userId).toEqual(subscription1.userId);
+ expect(subscription0.documentId).toEqual(subscription1.documentId);
+ });
+
+ it("should fetch deletedAt column with paranoid option", async () => {
+ const user = await buildUser();
+
+ const document = await buildDocument({
+ userId: user.id,
+ teamId: user.teamId,
+ });
+
+ const subscription0 = await sequelize.transaction(async (transaction) =>
+ subscriptionCreator({
+ user,
+ documentId: document.id,
+ event: subscribedEvent,
+ ip,
+ transaction,
+ })
+ );
+
+ const events = await Event.count();
+ expect(events).toEqual(1);
+
+ expect(subscription0.documentId).toEqual(document.id);
+ expect(subscription0.userId).toEqual(user.id);
+ expect(subscription0.userId).toEqual(user.id);
+ expect(subscription0.documentId).toEqual(document.id);
+ expect(subscription0.deletedAt).toEqual(null);
+ });
+});
diff --git a/server/commands/subscriptionCreator.ts b/server/commands/subscriptionCreator.ts
new file mode 100644
index 000000000..c7843d0be
--- /dev/null
+++ b/server/commands/subscriptionCreator.ts
@@ -0,0 +1,74 @@
+import { Transaction } from "sequelize";
+import { Subscription, Event, User } from "@server/models";
+
+type Props = {
+ /** The user creating the subscription */
+ user: User;
+ /** The document to subscribe to */
+ documentId?: string;
+ /** Event to subscribe to */
+ event: string;
+ /** The IP address of the incoming request */
+ ip: string;
+ /** Whether the subscription should be restored if it exists in a deleted state */
+ resubscribe?: boolean;
+ transaction: Transaction;
+};
+
+/**
+ * This command creates a subscription of a user to a document.
+ *
+ * @returns The subscription that was created
+ */
+export default async function subscriptionCreator({
+ user,
+ documentId,
+ event,
+ ip,
+ resubscribe = true,
+ transaction,
+}: Props): Promise {
+ const [subscription, created] = await Subscription.findOrCreate({
+ where: {
+ userId: user.id,
+ documentId,
+ event,
+ },
+ transaction,
+ // Previous subscriptions are soft-deleted, we want to know about them here
+ paranoid: false,
+ });
+
+ // If the subscription was deleted, then just restore the existing row.
+ if (subscription.deletedAt && resubscribe) {
+ subscription.restore({ transaction });
+
+ await Event.create(
+ {
+ name: "subscriptions.create",
+ modelId: subscription.id,
+ actorId: user.id,
+ userId: user.id,
+ documentId,
+ ip,
+ },
+ { transaction }
+ );
+ }
+
+ if (created) {
+ await Event.create(
+ {
+ name: "subscriptions.create",
+ modelId: subscription.id,
+ actorId: user.id,
+ userId: user.id,
+ documentId,
+ ip,
+ },
+ { transaction }
+ );
+ }
+
+ return subscription;
+}
diff --git a/server/commands/subscriptionDestroyer.test.ts b/server/commands/subscriptionDestroyer.test.ts
new file mode 100644
index 000000000..0618e72be
--- /dev/null
+++ b/server/commands/subscriptionDestroyer.test.ts
@@ -0,0 +1,101 @@
+import { sequelize } from "@server/database/sequelize";
+import { Subscription, Event } from "@server/models";
+import {
+ buildDocument,
+ buildSubscription,
+ buildUser,
+} from "@server/test/factories";
+import { getTestDatabase } from "@server/test/support";
+import subscriptionDestroyer from "./subscriptionDestroyer";
+
+const db = getTestDatabase();
+
+beforeEach(db.flush);
+afterAll(db.disconnect);
+
+describe("subscriptionDestroyer", () => {
+ const ip = "127.0.0.1";
+
+ it("should destroy existing subscription", async () => {
+ const user = await buildUser();
+
+ const document = await buildDocument({
+ userId: user.id,
+ teamId: user.teamId,
+ });
+
+ const subscription = await buildSubscription({
+ userId: user.id,
+ documentId: document.id,
+ });
+
+ await sequelize.transaction(
+ async (transaction) =>
+ await subscriptionDestroyer({
+ user,
+ subscription,
+ ip,
+ transaction,
+ })
+ );
+
+ const count = await Subscription.count();
+
+ expect(count).toEqual(0);
+
+ const event = await Event.findOne();
+
+ expect(event?.name).toEqual("subscriptions.delete");
+ expect(event?.modelId).toEqual(subscription.id);
+ expect(event?.actorId).toEqual(subscription.userId);
+ expect(event?.userId).toEqual(subscription.userId);
+ expect(event?.documentId).toEqual(subscription.documentId);
+ });
+
+ it("should soft delete row", async () => {
+ const user = await buildUser();
+
+ const document = await buildDocument({
+ userId: user.id,
+ teamId: user.teamId,
+ });
+
+ const subscription = await buildSubscription({
+ userId: user.id,
+ documentId: document.id,
+ });
+
+ await sequelize.transaction(
+ async (transaction) =>
+ await subscriptionDestroyer({
+ user,
+ subscription,
+ ip,
+ transaction,
+ })
+ );
+
+ const count = await Subscription.count();
+
+ expect(count).toEqual(0);
+
+ const event = await Event.findOne();
+
+ expect(event?.name).toEqual("subscriptions.delete");
+ expect(event?.modelId).toEqual(subscription.id);
+ expect(event?.actorId).toEqual(subscription.userId);
+ expect(event?.userId).toEqual(subscription.userId);
+ expect(event?.documentId).toEqual(subscription.documentId);
+
+ const deletedSubscription = await Subscription.findOne({
+ where: {
+ userId: user.id,
+ documentId: document.id,
+ },
+ paranoid: false,
+ });
+
+ expect(deletedSubscription).toBeDefined();
+ expect(deletedSubscription?.deletedAt).toBeDefined();
+ });
+});
diff --git a/server/commands/subscriptionDestroyer.ts b/server/commands/subscriptionDestroyer.ts
new file mode 100644
index 000000000..583552688
--- /dev/null
+++ b/server/commands/subscriptionDestroyer.ts
@@ -0,0 +1,41 @@
+import { Transaction } from "sequelize";
+import { Event, Subscription, User } from "@server/models";
+
+type Props = {
+ /** The user destroying the subscription */
+ user: User;
+ /** The subscription to destroy */
+ subscription: Subscription;
+ /** The IP address of the incoming request */
+ ip: string;
+ transaction: Transaction;
+};
+
+/**
+ * This command destroys a user subscription to a document so they will no
+ * longer receive notifications.
+ *
+ * @returns The subscription that was destroyed
+ */
+export default async function subscriptionDestroyer({
+ user,
+ subscription,
+ ip,
+ transaction,
+}: Props): Promise {
+ await subscription.destroy({ transaction });
+
+ await Event.create(
+ {
+ name: "subscriptions.delete",
+ modelId: subscription.id,
+ actorId: user.id,
+ userId: user.id,
+ documentId: subscription.documentId,
+ ip,
+ },
+ { transaction }
+ );
+
+ return subscription;
+}
diff --git a/server/migrations/20220719121200-create-subscriptions.js b/server/migrations/20220719121200-create-subscriptions.js
new file mode 100644
index 000000000..43747c23c
--- /dev/null
+++ b/server/migrations/20220719121200-create-subscriptions.js
@@ -0,0 +1,70 @@
+"use strict";
+
+module.exports = {
+ up: async (queryInterface, Sequelize) => {
+ await queryInterface.sequelize.transaction(async (transaction) => {
+ await queryInterface.createTable(
+ "subscriptions",
+ {
+ id: {
+ type: Sequelize.UUID,
+ allowNull: false,
+ primaryKey: true,
+ },
+ userId: {
+ type: Sequelize.UUID,
+ allowNull: false,
+ onDelete: "cascade",
+ references: {
+ model: "users",
+ },
+ },
+ documentId: {
+ type: Sequelize.UUID,
+ allowNull: true,
+ onDelete: "cascade",
+ references: {
+ model: "documents",
+ },
+ },
+ event: {
+ type: Sequelize.STRING,
+ allowNull: false,
+ },
+ createdAt: {
+ allowNull: false,
+ type: Sequelize.DATE,
+ },
+ updatedAt: {
+ allowNull: false,
+ type: Sequelize.DATE,
+ },
+ deletedAt: {
+ type: Sequelize.DATE,
+ allowNull: true,
+ },
+ },
+ { transaction }
+ );
+
+ await queryInterface.addIndex(
+ "subscriptions",
+ ["userId", "documentId", "event"],
+ {
+ name: "subscriptions_user_id_document_id_event",
+ type: "UNIQUE",
+ transaction,
+ }
+ );
+ });
+ },
+
+ down: async (queryInterface, Sequelize) => {
+ await queryInterface.removeIndex("subscriptions", [
+ "userId",
+ "documentId",
+ "event",
+ ]);
+ return queryInterface.dropTable("subscriptions");
+ },
+};
diff --git a/server/models/Document.ts b/server/models/Document.ts
index 459e0376b..2760803c7 100644
--- a/server/models/Document.ts
+++ b/server/models/Document.ts
@@ -794,10 +794,27 @@ class Document extends ParanoidModel {
return undefined;
};
+ /**
+ * Get a list of users that have collaborated on this document
+ *
+ * @param options FindOptions
+ * @returns A promise that resolve to a list of users
+ */
+ collaborators = async (options?: FindOptions): Promise => {
+ const users = await Promise.all(
+ this.collaboratorIds.map((collaboratorId) =>
+ User.findByPk(collaboratorId, options)
+ )
+ );
+
+ return compact(users);
+ };
+
/**
* Calculate all of the document ids that are children of this document by
* iterating through parentDocumentId references in the most efficient way.
*
+ * @param where query options to further filter the documents
* @param options FindOptions
* @returns A promise that resolves to a list of document ids
*/
diff --git a/server/models/NotificationSetting.ts b/server/models/NotificationSetting.ts
index 461503a3c..9934287e1 100644
--- a/server/models/NotificationSetting.ts
+++ b/server/models/NotificationSetting.ts
@@ -11,12 +11,22 @@ import {
IsIn,
Default,
DataType,
+ Scopes,
} from "sequelize-typescript";
import env from "@server/env";
import Team from "./Team";
import User from "./User";
import Fix from "./decorators/Fix";
+@Scopes(() => ({
+ withUser: {
+ include: [
+ {
+ association: "user",
+ },
+ ],
+ },
+}))
@Table({
tableName: "notification_settings",
modelName: "notification_setting",
diff --git a/server/models/Subscription.ts b/server/models/Subscription.ts
new file mode 100644
index 000000000..4ca5338dd
--- /dev/null
+++ b/server/models/Subscription.ts
@@ -0,0 +1,46 @@
+import {
+ Column,
+ DataType,
+ BelongsTo,
+ ForeignKey,
+ Table,
+ IsIn,
+ Scopes,
+} from "sequelize-typescript";
+import Document from "./Document";
+import User from "./User";
+import ParanoidModel from "./base/ParanoidModel";
+import Fix from "./decorators/Fix";
+
+@Scopes(() => ({
+ withUser: {
+ include: [
+ {
+ association: "user",
+ },
+ ],
+ },
+}))
+@Table({ tableName: "subscriptions", modelName: "subscription" })
+@Fix
+class Subscription extends ParanoidModel {
+ @BelongsTo(() => User, "userId")
+ user: User;
+
+ @ForeignKey(() => User)
+ @Column(DataType.UUID)
+ userId: string;
+
+ @BelongsTo(() => Document, "documentId")
+ document: Document | null;
+
+ @ForeignKey(() => Document)
+ @Column(DataType.UUID)
+ documentId: string | null;
+
+ @IsIn([["documents.update"]])
+ @Column(DataType.STRING)
+ event: string;
+}
+
+export default Subscription;
diff --git a/server/models/index.ts b/server/models/index.ts
index 3ce2dc91b..e58f8ed7e 100644
--- a/server/models/index.ts
+++ b/server/models/index.ts
@@ -53,3 +53,5 @@ export { default as View } from "./View";
export { default as WebhookSubscription } from "./WebhookSubscription";
export { default as WebhookDelivery } from "./WebhookDelivery";
+
+export { default as Subscription } from "./Subscription";
diff --git a/server/policies/document.ts b/server/policies/document.ts
index a39c37134..d55f46502 100644
--- a/server/policies/document.ts
+++ b/server/policies/document.ts
@@ -151,31 +151,36 @@ allow(User, "move", Document, (user, document) => {
return user.teamId === document.teamId;
});
-allow(User, ["pin", "unpin"], Document, (user, document) => {
- if (!document) {
- return false;
+allow(
+ User,
+ ["pin", "unpin", "subscribe", "unsubscribe"],
+ Document,
+ (user, document) => {
+ if (!document) {
+ return false;
+ }
+ if (document.archivedAt) {
+ return false;
+ }
+ if (document.deletedAt) {
+ return false;
+ }
+ if (document.template) {
+ return false;
+ }
+ if (!document.publishedAt) {
+ return false;
+ }
+ invariant(
+ document.collection,
+ "collection is missing, did you forget to include in the query scope?"
+ );
+ if (cannot(user, "update", document.collection)) {
+ return false;
+ }
+ return user.teamId === document.teamId;
}
- if (document.archivedAt) {
- return false;
- }
- if (document.deletedAt) {
- return false;
- }
- if (document.template) {
- return false;
- }
- if (!document.publishedAt) {
- return false;
- }
- invariant(
- document.collection,
- "collection is missing, did you forget to include in the query scope?"
- );
- if (cannot(user, "update", document.collection)) {
- return false;
- }
- return user.teamId === document.teamId;
-});
+);
allow(User, ["pinToHome"], Document, (user, document) => {
if (!document) {
diff --git a/server/policies/index.ts b/server/policies/index.ts
index 9f65f5dab..f5b357edd 100644
--- a/server/policies/index.ts
+++ b/server/policies/index.ts
@@ -20,6 +20,7 @@ import "./pins";
import "./searchQuery";
import "./share";
import "./star";
+import "./subscription";
import "./user";
import "./team";
import "./group";
diff --git a/server/policies/subscription.ts b/server/policies/subscription.ts
new file mode 100644
index 000000000..b600d4e25
--- /dev/null
+++ b/server/policies/subscription.ts
@@ -0,0 +1,21 @@
+import { Subscription, User } from "@server/models";
+import { allow } from "./cancan";
+
+allow(
+ User,
+ ["read", "update", "delete"],
+ Subscription,
+ (user, subscription) => {
+ if (!subscription) {
+ return false;
+ }
+
+ // If `user` is an admin, early exit with allow.
+ if (user.isAdmin) {
+ return true;
+ }
+
+ // User should be able to read their subscriptions.
+ return user.id === subscription.userId;
+ }
+);
diff --git a/server/presenters/index.ts b/server/presenters/index.ts
index a37327a7c..cf82fec15 100644
--- a/server/presenters/index.ts
+++ b/server/presenters/index.ts
@@ -17,6 +17,7 @@ import presentSearchQuery from "./searchQuery";
import presentShare from "./share";
import presentSlackAttachment from "./slackAttachment";
import presentStar from "./star";
+import presentSubscription from "./subscription";
import presentTeam from "./team";
import presentUser from "./user";
import presentView from "./view";
@@ -36,6 +37,7 @@ export {
presentShare,
presentSearchQuery,
presentStar,
+ presentSubscription,
presentTeam,
presentGroup,
presentIntegration,
diff --git a/server/presenters/subscription.ts b/server/presenters/subscription.ts
new file mode 100644
index 000000000..fcd142992
--- /dev/null
+++ b/server/presenters/subscription.ts
@@ -0,0 +1,12 @@
+import { Subscription } from "@server/models";
+
+export default function present(subscription: Subscription) {
+ return {
+ id: subscription.id,
+ userId: subscription.userId,
+ documentId: subscription.documentId,
+ event: subscription.event,
+ createdAt: subscription.createdAt,
+ updatedAt: subscription.updatedAt,
+ };
+}
diff --git a/server/queues/processors/NotificationsProcessor.test.ts b/server/queues/processors/NotificationsProcessor.test.ts
index 078cbcc8a..e3b8e7d87 100644
--- a/server/queues/processors/NotificationsProcessor.test.ts
+++ b/server/queues/processors/NotificationsProcessor.test.ts
@@ -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();
+ });
});
diff --git a/server/queues/processors/NotificationsProcessor.ts b/server/queues/processors/NotificationsProcessor.ts
index 729e823b9..4af311222 100644
--- a/server/queues/processors/NotificationsProcessor.ts
+++ b/server/queues/processors/NotificationsProcessor.ts
@@ -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 => {
+ 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 => {
+ // 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 => {
+ // 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 => {
+ // 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;
+ };
}
diff --git a/server/queues/tasks/DeliverWebhookTask.ts b/server/queues/tasks/DeliverWebhookTask.ts
index b9bd85113..cbe39d2f5 100644
--- a/server/queues/tasks/DeliverWebhookTask.ts
+++ b/server/queues/tasks/DeliverWebhookTask.ts
@@ -97,6 +97,8 @@ export default class DeliverWebhookTask extends BaseTask {
case "api_keys.delete":
case "attachments.create":
case "attachments.delete":
+ case "subscriptions.create":
+ case "subscriptions.delete":
case "authenticationProviders.update":
// Ignored
return;
diff --git a/server/routes/api/index.ts b/server/routes/api/index.ts
index d0e09c71a..d1c96ee42 100644
--- a/server/routes/api/index.ts
+++ b/server/routes/api/index.ts
@@ -27,6 +27,7 @@ import revisions from "./revisions";
import searches from "./searches";
import shares from "./shares";
import stars from "./stars";
+import subscriptions from "./subscriptions";
import team from "./team";
import users from "./users";
import views from "./views";
@@ -64,6 +65,7 @@ router.use("/", apiKeys.routes());
router.use("/", searches.routes());
router.use("/", shares.routes());
router.use("/", stars.routes());
+router.use("/", subscriptions.routes());
router.use("/", team.routes());
router.use("/", integrations.routes());
router.use("/", notificationSettings.routes());
diff --git a/server/routes/api/subscriptions.test.ts b/server/routes/api/subscriptions.test.ts
new file mode 100644
index 000000000..fc4cc9bd8
--- /dev/null
+++ b/server/routes/api/subscriptions.test.ts
@@ -0,0 +1,702 @@
+import { Event } from "@server/models";
+import {
+ buildUser,
+ buildSubscription,
+ buildDocument,
+} from "@server/test/factories";
+import { getTestDatabase, getTestServer } from "@server/test/support";
+
+const db = getTestDatabase();
+const server = getTestServer();
+
+afterAll(server.disconnect);
+
+beforeEach(db.flush);
+
+describe("#subscriptions.create", () => {
+ it("should create a subscription", async () => {
+ const user = await buildUser();
+
+ const document = await buildDocument({
+ userId: user.id,
+ teamId: user.teamId,
+ });
+
+ const res = await server.post("/api/subscriptions.create", {
+ body: {
+ token: user.getJwtToken(),
+ documentId: document.id,
+ event: "documents.update",
+ },
+ });
+
+ const body = await res.json();
+
+ expect(res.status).toEqual(200);
+ expect(body.data.id).toBeDefined();
+ expect(body.data.userId).toEqual(user.id);
+ expect(body.data.documentId).toEqual(document.id);
+ });
+
+ it("should emit event", async () => {
+ const user = await buildUser();
+
+ const document = await buildDocument({
+ userId: user.id,
+ teamId: user.teamId,
+ });
+
+ const res = await server.post("/api/subscriptions.create", {
+ body: {
+ token: user.getJwtToken(),
+ documentId: document.id,
+ event: "documents.update",
+ },
+ });
+
+ const body = await res.json();
+
+ expect(res.status).toEqual(200);
+ expect(body.ok).toEqual(true);
+
+ const events = await Event.findAll();
+
+ expect(events.length).toEqual(1);
+ expect(events[0].name).toEqual("subscriptions.create");
+ expect(events[0].actorId).toEqual(user.id);
+ expect(events[0].documentId).toEqual(document.id);
+ });
+
+ it("should not create duplicate subscriptions", async () => {
+ const user = await buildUser();
+
+ const document = await buildDocument({
+ userId: user.id,
+ teamId: user.teamId,
+ });
+
+ // First `subscriptions.create` request.
+ await server.post("/api/subscriptions.create", {
+ body: {
+ token: user.getJwtToken(),
+ documentId: document.id,
+ event: "documents.update",
+ },
+ });
+
+ // Second `subscriptions.create` request.
+ await server.post("/api/subscriptions.create", {
+ body: {
+ token: user.getJwtToken(),
+ documentId: document.id,
+ event: "documents.update",
+ },
+ });
+
+ // Third `subscriptions.create` request.
+ await server.post("/api/subscriptions.create", {
+ body: {
+ token: user.getJwtToken(),
+ documentId: document.id,
+ event: "documents.update",
+ },
+ });
+
+ // List subscriptions associated with
+ // `document.id`
+ const res = await server.post("/api/subscriptions.list", {
+ body: {
+ token: user.getJwtToken(),
+ documentId: document.id,
+ event: "documents.update",
+ },
+ });
+
+ const body = await res.json();
+
+ expect(res.status).toEqual(200);
+ // Database should only have 1 "enabled" subscription registered.
+ expect(body.data.length).toEqual(1);
+ expect(body.data[0].userId).toEqual(user.id);
+ expect(body.data[0].documentId).toEqual(document.id);
+ });
+
+ it("should not create a subscription on invalid events", async () => {
+ const user = await buildUser();
+
+ const document = await buildDocument({
+ userId: user.id,
+ teamId: user.teamId,
+ });
+
+ const res = await server.post("/api/subscriptions.create", {
+ body: {
+ token: user.getJwtToken(),
+ documentId: document.id,
+ // Subscription on event
+ // that cannot be subscribed to.
+ event: "documents.publish",
+ },
+ });
+
+ const body = await res.json();
+
+ expect(res.status).toEqual(400);
+ expect(body.ok).toEqual(false);
+ expect(body.error).toEqual("validation_error");
+ expect(body.message).toEqual(
+ "Not a valid subscription event for documents"
+ );
+ });
+});
+
+describe("#subscriptions.info", () => {
+ it("should provide info about a subscription", async () => {
+ const creator = await buildUser();
+
+ const subscriber = await buildUser({ teamId: creator.teamId });
+
+ // `creator` creates two documents
+ const document0 = await buildDocument({
+ userId: creator.id,
+ teamId: creator.teamId,
+ });
+
+ const document1 = await buildDocument({
+ userId: creator.id,
+ teamId: creator.teamId,
+ });
+
+ // `subscriber` subscribes to `document0`.
+ await server.post("/api/subscriptions.create", {
+ body: {
+ token: subscriber.getJwtToken(),
+ documentId: document0.id,
+ event: "documents.update",
+ },
+ });
+
+ // `subscriber` subscribes to `document1`.
+ await server.post("/api/subscriptions.create", {
+ body: {
+ token: subscriber.getJwtToken(),
+ documentId: document1.id,
+ event: "documents.update",
+ },
+ });
+
+ // `subscriber` wants info about
+ // their subscription on `document0`.
+ const subscription0 = await server.post("/api/subscriptions.info", {
+ body: {
+ token: subscriber.getJwtToken(),
+ documentId: document0.id,
+ event: "documents.update",
+ },
+ });
+
+ const response0 = await subscription0.json();
+
+ expect(subscription0.status).toEqual(200);
+ expect(response0.data.id).toBeDefined();
+ expect(response0.data.userId).toEqual(subscriber.id);
+ expect(response0.data.documentId).toEqual(document0.id);
+ });
+
+ it("should not allow outsiders to gain info about a subscription", async () => {
+ const creator = await buildUser();
+ const subscriber = await buildUser({ teamId: creator.teamId });
+ // `viewer` is not a part of `creator`'s team.
+ const viewer = await buildUser();
+
+ // `creator` creates two documents
+ const document0 = await buildDocument({
+ userId: creator.id,
+ teamId: creator.teamId,
+ });
+
+ const document1 = await buildDocument({
+ userId: creator.id,
+ teamId: creator.teamId,
+ });
+
+ // `subscriber` subscribes to `document0`.
+ await server.post("/api/subscriptions.create", {
+ body: {
+ token: subscriber.getJwtToken(),
+ documentId: document0.id,
+ event: "documents.update",
+ },
+ });
+
+ // `subscriber` subscribes to `document1`.
+ await server.post("/api/subscriptions.create", {
+ body: {
+ token: subscriber.getJwtToken(),
+ documentId: document1.id,
+ event: "documents.update",
+ },
+ });
+
+ // `viewer` wants info about `subscriber`'s
+ // subscription on `document0`.
+ const subscription0 = await server.post("/api/subscriptions.info", {
+ body: {
+ token: viewer.getJwtToken(),
+ documentId: document0.id,
+ event: "documents.update",
+ },
+ });
+
+ const response0 = await subscription0.json();
+
+ // `viewer` should be unauthorized.
+ expect(subscription0.status).toEqual(403);
+ expect(response0.ok).toEqual(false);
+ expect(response0.error).toEqual("authorization_error");
+ expect(response0.message).toEqual("Authorization error");
+
+ // `viewer` wants info about `subscriber`'s
+ // subscription on `document0`.
+ const subscription1 = await server.post("/api/subscriptions.info", {
+ body: {
+ token: viewer.getJwtToken(),
+ documentId: document1.id,
+ event: "documents.update",
+ },
+ });
+
+ const response1 = await subscription1.json();
+
+ // `viewer` should be unauthorized.
+ expect(subscription1.status).toEqual(403);
+ expect(response1.ok).toEqual(false);
+ expect(response1.error).toEqual("authorization_error");
+ expect(response1.message).toEqual("Authorization error");
+ });
+
+ it("should not provide infomation on invalid events", async () => {
+ const creator = await buildUser();
+
+ const subscriber = await buildUser({ teamId: creator.teamId });
+ // `viewer` is a part of `creator`'s team.
+ const viewer = await buildUser({ teamId: creator.teamId });
+
+ // `creator` creates two documents
+ const document0 = await buildDocument({
+ userId: creator.id,
+ teamId: creator.teamId,
+ });
+
+ const document1 = await buildDocument({
+ userId: creator.id,
+ teamId: creator.teamId,
+ });
+
+ // `subscriber` subscribes to `document0`.
+ await server.post("/api/subscriptions.create", {
+ body: {
+ token: subscriber.getJwtToken(),
+ documentId: document0.id,
+ event: "documents.update",
+ },
+ });
+
+ // `subscriber` subscribes to `document1`.
+ await server.post("/api/subscriptions.create", {
+ body: {
+ token: subscriber.getJwtToken(),
+ documentId: document1.id,
+ event: "documents.update",
+ },
+ });
+
+ // `viewer` wants info about `subscriber`'s
+ // subscription on `document0`.
+ // They have requested an invalid event.
+ const subscription0 = await server.post("/api/subscriptions.info", {
+ body: {
+ token: viewer.getJwtToken(),
+ documentId: document0.id,
+ event: "documents.changed",
+ },
+ });
+
+ const response0 = await subscription0.json();
+
+ expect(subscription0.status).toEqual(400);
+ expect(response0.ok).toEqual(false);
+ expect(response0.error).toEqual("validation_error");
+ expect(response0.message).toEqual(
+ "Not a valid subscription event for documents"
+ );
+
+ // `viewer` wants info about `subscriber`'s
+ // subscription on `document0`.
+ // They have requested an invalid event.
+ const subscription1 = await server.post("/api/subscriptions.info", {
+ body: {
+ token: viewer.getJwtToken(),
+ documentId: document1.id,
+ event: "doc.affected",
+ },
+ });
+
+ const response1 = await subscription1.json();
+
+ expect(subscription1.status).toEqual(400);
+ expect(response1.ok).toEqual(false);
+ expect(response1.error).toEqual("validation_error");
+ expect(response1.message).toEqual(
+ "Not a valid subscription event for documents"
+ );
+ });
+});
+
+describe("#subscriptions.list", () => {
+ it("should list user subscriptions", async () => {
+ const user = await buildUser();
+
+ const document = await buildDocument({
+ userId: user.id,
+ teamId: user.teamId,
+ });
+
+ await buildSubscription();
+
+ const subscription = await buildSubscription({
+ userId: user.id,
+ documentId: document.id,
+ event: "documents.update",
+ });
+
+ const res = await server.post("/api/subscriptions.list", {
+ body: {
+ token: user.getJwtToken(),
+ documentId: document.id,
+ event: "documents.update",
+ },
+ });
+
+ const body = await res.json();
+
+ expect(res.status).toEqual(200);
+ expect(body.data.length).toEqual(1);
+ expect(body.data[0].id).toEqual(subscription.id);
+ expect(body.data[0].userId).toEqual(user.id);
+ expect(body.data[0].documentId).toEqual(document.id);
+ });
+
+ it("user should be able to list subscriptions on document", async () => {
+ const subscriber0 = await buildUser();
+ // `subscriber1` belongs to `subscriber0`'s team.
+ const subscriber1 = await buildUser({ teamId: subscriber0.teamId });
+ // `viewer` belongs to `subscriber0`'s team.
+ const viewer = await buildUser({ teamId: subscriber0.teamId });
+
+ // `subscriber0` created a document.
+ const document = await buildDocument({
+ userId: subscriber0.id,
+ teamId: subscriber0.teamId,
+ });
+
+ // `subscriber0` wants to be notified about
+ // changes on this document.
+ await server.post("/api/subscriptions.create", {
+ body: {
+ token: subscriber0.getJwtToken(),
+ documentId: document.id,
+ event: "documents.update",
+ },
+ });
+
+ // `subscriber1` wants to be notified about
+ // changes on this document.
+ await server.post("/api/subscriptions.create", {
+ body: {
+ token: subscriber1.getJwtToken(),
+ documentId: document.id,
+ event: "documents.update",
+ },
+ });
+
+ // `viewer` just wants to know the subscribers
+ // for this document.
+ const res = await server.post("/api/subscriptions.list", {
+ body: {
+ token: viewer.getJwtToken(),
+ documentId: document.id,
+ event: "documents.update",
+ },
+ });
+
+ const body = await res.json();
+
+ expect(res.status).toEqual(200);
+ // `viewer` doesn't have any subscriptions on `document`.
+ expect(body.data.length).toEqual(0);
+
+ // `subscriber0` wants to know the subscribers
+ // for this document.
+ const res0 = await server.post("/api/subscriptions.list", {
+ body: {
+ token: subscriber0.getJwtToken(),
+ documentId: document.id,
+ event: "documents.update",
+ },
+ });
+
+ const body0 = await res0.json();
+
+ // `subscriber1` subscribed after `subscriber0`
+ expect(body0.data[0].userId).toEqual(subscriber0.id);
+ // Both subscribers subscribed to same `document`.
+ expect(body0.data[0].documentId).toEqual(document.id);
+
+ // `subscriber1` wants to know the subscribers
+ // for this document.
+ const res1 = await server.post("/api/subscriptions.list", {
+ body: {
+ token: subscriber1.getJwtToken(),
+ documentId: document.id,
+ event: "documents.update",
+ },
+ });
+
+ const body1 = await res1.json();
+
+ // `subscriber1` subscribed after `subscriber1`
+ expect(body1.data[0].userId).toEqual(subscriber1.id);
+ // Both subscribers subscribed to same `document`.
+ expect(body1.data[0].documentId).toEqual(document.id);
+ });
+
+ it("user should not be able to list invalid subscriptions", async () => {
+ const subscriber0 = await buildUser();
+ // `subscriber1` belongs to `subscriber0`'s team.
+ const subscriber1 = await buildUser({ teamId: subscriber0.teamId });
+ // `viewer` belongs to `subscriber0`'s team.
+ const viewer = await buildUser({ teamId: subscriber0.teamId });
+
+ // `subscriber0` created a document.
+ const document = await buildDocument({
+ userId: subscriber0.id,
+ teamId: subscriber0.teamId,
+ });
+
+ // `subscriber0` wants to be notified about
+ // changes on this document.
+ await server.post("/api/subscriptions.create", {
+ body: {
+ token: subscriber0.getJwtToken(),
+ documentId: document.id,
+ event: "documents.update",
+ },
+ });
+
+ // `subscriber1` wants to be notified about
+ // changes on this document.
+ await server.post("/api/subscriptions.create", {
+ body: {
+ token: subscriber1.getJwtToken(),
+ documentId: document.id,
+ event: "documents.update",
+ },
+ });
+
+ // `viewer` just wants to know the subscribers
+ // for this document.
+ const res = await server.post("/api/subscriptions.list", {
+ body: {
+ token: viewer.getJwtToken(),
+ documentId: document.id,
+ event: "changes.on.documents",
+ },
+ });
+
+ const body = await res.json();
+
+ expect(res.status).toEqual(400);
+ expect(body.ok).toEqual(false);
+ expect(body.error).toEqual("validation_error");
+ expect(body.message).toEqual(
+ "Not a valid subscription event for documents"
+ );
+ });
+
+ it("user outside of the team should not be able to list subscriptions on internal document", async () => {
+ const subscriber0 = await buildUser();
+ // `subscriber1` belongs to `subscriber0`'s team.
+ const subscriber1 = await buildUser({ teamId: subscriber0.teamId });
+ // `viewer` belongs to a different team.
+ const viewer = await buildUser();
+
+ // `subscriber0` created a document.
+ const document = await buildDocument({
+ userId: subscriber0.id,
+ teamId: subscriber0.teamId,
+ });
+
+ // `subscriber0` wants to be notified about
+ // changes on this document.
+ await server.post("/api/subscriptions.create", {
+ body: {
+ token: subscriber0.getJwtToken(),
+ documentId: document.id,
+ event: "documents.update",
+ },
+ });
+
+ // `subscriber1` wants to be notified about
+ // changes on this document.
+ await server.post("/api/subscriptions.create", {
+ body: {
+ token: subscriber1.getJwtToken(),
+ documentId: document.id,
+ event: "documents.update",
+ },
+ });
+
+ // `viewer` wants to know the subscribers
+ // for this internal document.
+ const res = await server.post("/api/subscriptions.info", {
+ body: {
+ token: viewer.getJwtToken(),
+ documentId: document.id,
+ event: "documents.update",
+ },
+ });
+
+ const body = await res.json();
+
+ // `viewer` should not be authorized
+ // to view subscriptions on this document.
+ expect(res.status).toEqual(403);
+ expect(body.ok).toEqual(false);
+ expect(body.error).toEqual("authorization_error");
+ expect(body.message).toEqual("Authorization error");
+ });
+});
+
+describe("#subscriptions.delete", () => {
+ it("should delete user's subscription", async () => {
+ const user = await buildUser();
+
+ const document = await buildDocument({
+ userId: user.id,
+ teamId: user.teamId,
+ });
+
+ const subscription = await buildSubscription({
+ userId: user.id,
+ documentId: document.id,
+ event: "documents.update",
+ });
+
+ const res = await server.post("/api/subscriptions.delete", {
+ body: {
+ userId: user.id,
+ id: subscription.id,
+ token: user.getJwtToken(),
+ },
+ });
+
+ const body = await res.json();
+
+ expect(res.status).toEqual(200);
+ expect(body.ok).toEqual(true);
+ expect(body.success).toEqual(true);
+ });
+
+ it("should emit event", async () => {
+ const user = await buildUser();
+
+ const document = await buildDocument({
+ userId: user.id,
+ teamId: user.teamId,
+ });
+
+ const subscription = await buildSubscription({
+ userId: user.id,
+ documentId: document.id,
+ event: "documents.update",
+ });
+
+ const res = await server.post("/api/subscriptions.delete", {
+ body: {
+ userId: user.id,
+ id: subscription.id,
+ token: user.getJwtToken(),
+ },
+ });
+
+ const events = await Event.findAll();
+
+ expect(events.length).toEqual(1);
+ expect(events[0].name).toEqual("subscriptions.delete");
+ expect(events[0].modelId).toEqual(subscription.id);
+ expect(events[0].actorId).toEqual(user.id);
+ expect(events[0].documentId).toEqual(document.id);
+
+ const body = await res.json();
+
+ expect(res.status).toEqual(200);
+ expect(body.ok).toEqual(true);
+ expect(body.success).toEqual(true);
+ });
+
+ it("users should not be able to delete other's subscriptions on document", async () => {
+ const subscriber0 = await buildUser();
+ // `subscriber1` belongs to `subscriber0`'s team.
+ const subscriber1 = await buildUser({ teamId: subscriber0.teamId });
+
+ // `subscriber0` created a document.
+ const document = await buildDocument({
+ userId: subscriber0.id,
+ teamId: subscriber0.teamId,
+ });
+
+ // `subscriber0` wants to be notified about
+ // changes on this document.
+ await server.post("/api/subscriptions.create", {
+ body: {
+ token: subscriber0.getJwtToken(),
+ documentId: document.id,
+ event: "documents.update",
+ },
+ });
+
+ // `subscriber1` wants to be notified about
+ // changes on this document.
+ const resp = await server.post("/api/subscriptions.create", {
+ body: {
+ token: subscriber1.getJwtToken(),
+ documentId: document.id,
+ event: "documents.update",
+ },
+ });
+
+ const subscription1 = await resp.json();
+ const subscription1Id = subscription1.data.id;
+
+ // `subscriber0` wants to change `subscriber1`'s
+ // subscription for this document.
+ const res = await server.post("/api/subscriptions.delete", {
+ body: {
+ // `subscriber0`
+ userId: subscriber0.id,
+ // subscription id of `subscriber1`
+ id: subscription1Id,
+ token: subscriber0.getJwtToken(),
+ },
+ });
+
+ const body = await res.json();
+
+ // `subscriber0` should be unauthorized.
+ expect(res.status).toEqual(403);
+ expect(body.ok).toEqual(false);
+ expect(body.error).toEqual("authorization_error");
+ expect(body.message).toEqual("Authorization error");
+ });
+});
diff --git a/server/routes/api/subscriptions.ts b/server/routes/api/subscriptions.ts
new file mode 100644
index 000000000..54990bf9d
--- /dev/null
+++ b/server/routes/api/subscriptions.ts
@@ -0,0 +1,141 @@
+import Router from "koa-router";
+import subscriptionCreator from "@server/commands/subscriptionCreator";
+import subscriptionDestroyer from "@server/commands/subscriptionDestroyer";
+import { sequelize } from "@server/database/sequelize";
+import auth from "@server/middlewares/authentication";
+import { Subscription, Document } from "@server/models";
+import { authorize } from "@server/policies";
+import { presentSubscription } from "@server/presenters";
+import { assertIn, assertUuid } from "@server/validation";
+import pagination from "./middlewares/pagination";
+
+const router = new Router();
+
+router.post("subscriptions.list", auth(), pagination(), async (ctx) => {
+ const { user } = ctx.state;
+ const { documentId, event } = ctx.body;
+
+ assertUuid(documentId, "documentId is required");
+
+ assertIn(
+ event,
+ ["documents.update"],
+ `Not a valid subscription event for documents`
+ );
+
+ const document = await Document.findByPk(documentId, { userId: user.id });
+
+ authorize(user, "read", document);
+
+ const subscriptions = await Subscription.findAll({
+ where: {
+ documentId: document.id,
+ userId: user.id,
+ event,
+ },
+ order: [["createdAt", "DESC"]],
+ offset: ctx.state.pagination.offset,
+ limit: ctx.state.pagination.limit,
+ });
+
+ ctx.body = {
+ pagination: ctx.state.pagination,
+ data: subscriptions.map(presentSubscription),
+ };
+});
+
+router.post("subscriptions.info", auth(), async (ctx) => {
+ const { user } = ctx.state;
+ const { documentId, event } = ctx.body;
+
+ assertUuid(documentId, "documentId is required");
+
+ assertIn(
+ event,
+ ["documents.update"],
+ "Not a valid subscription event for documents"
+ );
+
+ const document = await Document.findByPk(documentId, { userId: user.id });
+
+ authorize(user, "read", document);
+
+ // There can be only one subscription with these props.
+ const subscription = await Subscription.findOne({
+ where: {
+ userId: user.id,
+ documentId: document.id,
+ event,
+ },
+ rejectOnEmpty: true,
+ });
+
+ ctx.body = {
+ data: presentSubscription(subscription),
+ };
+});
+
+router.post("subscriptions.create", auth(), async (ctx) => {
+ const { user } = ctx.state;
+ const { documentId, event } = ctx.body;
+
+ assertUuid(documentId, "documentId is required");
+
+ assertIn(
+ event,
+ ["documents.update"],
+ "Not a valid subscription event for documents"
+ );
+
+ const subscription = await sequelize.transaction(async (transaction) => {
+ const document = await Document.findByPk(documentId, {
+ userId: user.id,
+ transaction,
+ });
+
+ authorize(user, "subscribe", document);
+
+ return subscriptionCreator({
+ user,
+ documentId: document.id,
+ event,
+ ip: ctx.request.ip,
+ transaction,
+ });
+ });
+
+ ctx.body = {
+ data: presentSubscription(subscription),
+ };
+});
+
+router.post("subscriptions.delete", auth(), async (ctx) => {
+ const { user } = ctx.state;
+ const { id } = ctx.body;
+
+ assertUuid(id, "id is required");
+
+ await sequelize.transaction(async (transaction) => {
+ const subscription = await Subscription.findByPk(id, {
+ rejectOnEmpty: true,
+ transaction,
+ });
+
+ authorize(user, "delete", subscription);
+
+ await subscriptionDestroyer({
+ user,
+ subscription,
+ ip: ctx.request.ip,
+ transaction,
+ });
+
+ return subscription;
+ });
+
+ ctx.body = {
+ success: true,
+ };
+});
+
+export default router;
diff --git a/server/scripts/20220722000000-backfill-subscriptions.test.ts b/server/scripts/20220722000000-backfill-subscriptions.test.ts
new file mode 100644
index 000000000..a157a2da0
--- /dev/null
+++ b/server/scripts/20220722000000-backfill-subscriptions.test.ts
@@ -0,0 +1,176 @@
+import { Subscription } from "@server/models";
+import { buildDocument, buildUser } from "@server/test/factories";
+import { getTestDatabase } from "@server/test/support";
+import script from "./20220722000000-backfill-subscriptions";
+
+const db = getTestDatabase();
+
+beforeEach(db.flush);
+afterAll(db.disconnect);
+
+describe("#work", () => {
+ it("should create subscriptions and subscriptions for document creator and collaborators", async () => {
+ const admin = await buildUser();
+
+ // 5 collaborators that have cyclically contributed to documents.
+ const collaborator0 = await buildUser({ teamId: admin.teamId });
+ const collaborator1 = await buildUser({ teamId: admin.teamId });
+ const collaborator2 = await buildUser({ teamId: admin.teamId });
+ const collaborator3 = await buildUser({ teamId: admin.teamId });
+ const collaborator4 = await buildUser({ teamId: admin.teamId });
+
+ const document0 = await buildDocument({
+ userId: collaborator0.id,
+ collaboratorIds: [collaborator1.id, collaborator2.id],
+ });
+
+ const document1 = await buildDocument({
+ userId: collaborator1.id,
+ collaboratorIds: [collaborator2.id, collaborator3.id],
+ });
+
+ const document2 = await buildDocument({
+ userId: collaborator2.id,
+ collaboratorIds: [collaborator3.id, collaborator4.id],
+ });
+
+ const document3 = await buildDocument({
+ userId: collaborator3.id,
+ collaboratorIds: [collaborator4.id, collaborator0.id],
+ });
+
+ const document4 = await buildDocument({
+ userId: collaborator4.id,
+ collaboratorIds: [collaborator0.id, collaborator1.id],
+ });
+
+ await script();
+
+ const subscriptions = await Subscription.findAll();
+
+ subscriptions.forEach((subscription) => {
+ expect(subscription.id).toBeDefined();
+ expect(subscription.event).toEqual("documents.update");
+ });
+
+ // 5 documents, 3 collaborators each = 15.
+ expect(subscriptions.length).toEqual(15);
+
+ expect(subscriptions[0].documentId).toEqual(document0.id);
+ expect(subscriptions[1].documentId).toEqual(document0.id);
+ expect(subscriptions[2].documentId).toEqual(document0.id);
+
+ const s0 = [
+ subscriptions[0].userId,
+ subscriptions[1].userId,
+ subscriptions[2].userId,
+ ];
+
+ expect(s0.some((s) => s.includes(collaborator0.id))).toBe(true);
+ expect(s0.some((s) => s.includes(collaborator1.id))).toBe(true);
+ expect(s0.some((s) => s.includes(collaborator2.id))).toBe(true);
+
+ expect(subscriptions[3].documentId).toEqual(document1.id);
+ expect(subscriptions[4].documentId).toEqual(document1.id);
+ expect(subscriptions[5].documentId).toEqual(document1.id);
+
+ const s1 = [
+ subscriptions[3].userId,
+ subscriptions[4].userId,
+ subscriptions[5].userId,
+ ];
+
+ expect(s1.some((s) => s.includes(collaborator1.id))).toBe(true);
+ expect(s1.some((s) => s.includes(collaborator2.id))).toBe(true);
+ expect(s1.some((s) => s.includes(collaborator3.id))).toBe(true);
+
+ expect(subscriptions[6].documentId).toEqual(document2.id);
+ expect(subscriptions[7].documentId).toEqual(document2.id);
+ expect(subscriptions[8].documentId).toEqual(document2.id);
+
+ const s2 = [
+ subscriptions[6].userId,
+ subscriptions[7].userId,
+ subscriptions[8].userId,
+ ];
+
+ expect(s2.some((s) => s.includes(collaborator2.id))).toBe(true);
+ expect(s2.some((s) => s.includes(collaborator3.id))).toBe(true);
+ expect(s2.some((s) => s.includes(collaborator4.id))).toBe(true);
+
+ expect(subscriptions[9].documentId).toEqual(document3.id);
+ expect(subscriptions[10].documentId).toEqual(document3.id);
+ expect(subscriptions[11].documentId).toEqual(document3.id);
+
+ const s3 = [
+ subscriptions[9].userId,
+ subscriptions[10].userId,
+ subscriptions[11].userId,
+ ];
+
+ expect(s3.some((s) => s.includes(collaborator0.id))).toBe(true);
+ expect(s3.some((s) => s.includes(collaborator3.id))).toBe(true);
+ expect(s3.some((s) => s.includes(collaborator4.id))).toBe(true);
+
+ expect(subscriptions[12].documentId).toEqual(document4.id);
+ expect(subscriptions[13].documentId).toEqual(document4.id);
+ expect(subscriptions[14].documentId).toEqual(document4.id);
+
+ const s4 = [
+ subscriptions[12].userId,
+ subscriptions[13].userId,
+ subscriptions[14].userId,
+ ];
+
+ expect(s4.some((s) => s.includes(collaborator0.id))).toBe(true);
+ expect(s4.some((s) => s.includes(collaborator1.id))).toBe(true);
+ expect(s4.some((s) => s.includes(collaborator4.id))).toBe(true);
+ });
+
+ it("should not create subscriptions and subscriptions for non-collaborators", async () => {
+ const admin = await buildUser();
+
+ // 2 collaborators.
+ const collaborator0 = await buildUser({ teamId: admin.teamId });
+ const collaborator1 = await buildUser({ teamId: admin.teamId });
+
+ // 1 viewer from the same team.
+ const viewer = await buildUser({ teamId: admin.teamId });
+
+ const document0 = await buildDocument({
+ userId: collaborator0.id,
+ collaboratorIds: [collaborator1.id],
+ });
+
+ await script();
+
+ const subscriptions = await Subscription.findAll();
+
+ subscriptions.forEach((subscription) => {
+ expect(subscription.id).toBeDefined();
+ });
+
+ expect(
+ subscriptions.filter((subscription) => subscription.userId === viewer.id)
+ .length
+ ).toEqual(0);
+
+ expect(subscriptions[0].documentId).toEqual(document0.id);
+ expect(subscriptions[1].documentId).toEqual(document0.id);
+ expect(subscriptions.map((s) => s.userId)).toContain(collaborator1.id);
+ expect(subscriptions.map((s) => s.userId)).toContain(collaborator0.id);
+ expect(subscriptions[0].event).toEqual("documents.update");
+ expect(subscriptions[1].event).toEqual("documents.update");
+ });
+
+ it("should be idempotent", async () => {
+ await buildDocument();
+
+ await script();
+ await script();
+
+ const count = await Subscription.count();
+
+ expect(count).toEqual(1);
+ });
+});
diff --git a/server/scripts/20220722000000-backfill-subscriptions.ts b/server/scripts/20220722000000-backfill-subscriptions.ts
new file mode 100644
index 000000000..d51dd901d
--- /dev/null
+++ b/server/scripts/20220722000000-backfill-subscriptions.ts
@@ -0,0 +1,51 @@
+import "./bootstrap";
+import { Subscription, Document } from "@server/models";
+
+const limit = 100;
+let page = parseInt(process.argv[2], 10);
+page = Number.isNaN(page) ? 0 : page;
+
+export default async function main(exit = false) {
+ const work = async (page: number) => {
+ console.log(`Backfill subscription… page ${page}`);
+
+ // Retrieve all documents within set limit.
+ const documents = await Document.findAll({
+ attributes: ["collaboratorIds", "id"],
+ limit,
+ offset: page * limit,
+ order: [["createdAt", "ASC"]],
+ });
+
+ for (const document of documents) {
+ try {
+ await Promise.all(
+ document.collaboratorIds.map((collaboratorId) =>
+ Subscription.findOrCreate({
+ where: {
+ userId: collaboratorId,
+ documentId: document.id,
+ event: "documents.update",
+ },
+ })
+ )
+ );
+ } catch (err) {
+ console.error(`Failed at ${document.id}:`, err);
+
+ continue;
+ }
+ }
+ };
+
+ await work(page);
+
+ if (exit) {
+ console.log("Backfill complete");
+ process.exit(0);
+ }
+}
+
+if (process.env.NODE_ENV !== "test") {
+ main(true);
+}
diff --git a/server/scripts/bootstrap.ts b/server/scripts/bootstrap.ts
index 8be8dee42..f64a7a140 100644
--- a/server/scripts/bootstrap.ts
+++ b/server/scripts/bootstrap.ts
@@ -5,7 +5,6 @@ if (process.env.NODE_ENV !== "test") {
});
}
-// @ts-expect-error ts-migrate(2322) FIXME: Type 'true' is not assignable to type 'string | un... Remove this comment to see the full error message
-process.env.SINGLE_RUN = true;
+require("../database/sequelize");
export {};
diff --git a/server/test/factories.ts b/server/test/factories.ts
index 426052007..b04ae4b3a 100644
--- a/server/test/factories.ts
+++ b/server/test/factories.ts
@@ -17,6 +17,7 @@ import {
WebhookSubscription,
WebhookDelivery,
ApiKey,
+ Subscription,
} from "@server/models";
import {
FileOperationState,
@@ -88,6 +89,31 @@ export async function buildStar(overrides: Partial = {}) {
});
}
+export async function buildSubscription(overrides: Partial = {}) {
+ let user;
+
+ if (overrides.userId) {
+ user = await User.findByPk(overrides.userId);
+ } else {
+ user = await buildUser();
+ overrides.userId = user.id;
+ }
+
+ if (!overrides.documentId) {
+ const document = await buildDocument({
+ createdById: overrides.userId,
+ teamId: user?.teamId,
+ });
+ overrides.documentId = document.id;
+ }
+
+ return Subscription.create({
+ enabled: true,
+ event: "documents.update",
+ ...overrides,
+ });
+}
+
export function buildTeam(overrides: Record = {}) {
count++;
return Team.create(
diff --git a/server/types.ts b/server/types.ts
index eb40f2f8c..3b781ab34 100644
--- a/server/types.ts
+++ b/server/types.ts
@@ -257,6 +257,13 @@ export type ShareEvent = BaseEvent & {
};
};
+export type SubscriptionEvent = BaseEvent & {
+ name: "subscriptions.create" | "subscriptions.delete";
+ modelId: string;
+ userId: string;
+ documentId: string | null;
+};
+
export type ViewEvent = BaseEvent & {
name: "views.create";
documentId: string;
@@ -293,6 +300,7 @@ export type Event =
| GroupEvent
| RevisionEvent
| ShareEvent
+ | SubscriptionEvent
| TeamEvent
| UserEvent
| ViewEvent
diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json
index 74f96cde9..8015d3543 100644
--- a/shared/i18n/locales/en_US/translation.json
+++ b/shared/i18n/locales/en_US/translation.json
@@ -10,6 +10,10 @@
"Developer": "Developer",
"Open document": "Open document",
"New document": "New document",
+ "Subscribe": "Subscribe",
+ "Subscribed to document notifications": "Subscribed to document notifications",
+ "Unsubscribe": "Unsubscribe",
+ "Unsubscribed from document notifications": "Unsubscribed from document notifications",
"Download": "Download",
"Download document": "Download document",
"Duplicate": "Duplicate",
diff --git a/yarn.lock b/yarn.lock
index 754fb6a51..0d7c261c1 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -11315,10 +11315,10 @@ os-browserify@^0.3.0:
resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27"
integrity sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc=
-outline-icons@^1.43.1:
- version "1.43.1"
- resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.43.1.tgz#3193c4c659c66b34788db043bb2f843b9c437a48"
- integrity sha512-REj+JsCFi2Jv5uG0/OrBsMVSBFAIsSROxynWbuO9r2eNT8wdqjni02Mk1gq1qFfTbwOvHJ+7ycadu6zlISAK2g==
+outline-icons@^1.44.0:
+ version "1.44.0"
+ resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.44.0.tgz#ce9fd272f0556db9b05b5615f87c12b16bd18ea0"
+ integrity sha512-nkKGXuGbOgZjPkyVpZZu7CIDrfmt2eER+3RWfE1LU/GqHkuUt0c5JpCsEyhxXAPMUW09q4sDvHjLVge7DUWeYg==
oy-vey@^0.11.2:
version "0.11.2"