From 24c71c38a5cfa9568d3ad6cb86c9b3d5e47429bc Mon Sep 17 00:00:00 2001 From: CuriousCorrelation <58817502+CuriousCorrelation@users.noreply.github.com> Date: Fri, 26 Aug 2022 12:17:13 +0530 Subject: [PATCH] feat: Document subscriptions (#3834) Co-authored-by: Tom Moor --- app/actions/definitions/documents.tsx | 66 ++ app/menus/DocumentMenu.tsx | 11 +- app/models/Document.ts | 47 +- app/models/Subscription.ts | 29 + app/scenes/Document/components/DataLoader.tsx | 19 +- app/stores/DocumentsStore.ts | 27 +- app/stores/RootStore.ts | 4 + app/stores/SubscriptionsStore.ts | 11 + package.json | 2 +- server/commands/subscriptionCreator.test.ts | 275 +++++++ server/commands/subscriptionCreator.ts | 74 ++ server/commands/subscriptionDestroyer.test.ts | 101 +++ server/commands/subscriptionDestroyer.ts | 41 + .../20220719121200-create-subscriptions.js | 70 ++ server/models/Document.ts | 17 + server/models/NotificationSetting.ts | 10 + server/models/Subscription.ts | 46 ++ server/models/index.ts | 2 + server/policies/document.ts | 53 +- server/policies/index.ts | 1 + server/policies/subscription.ts | 21 + server/presenters/index.ts | 2 + server/presenters/subscription.ts | 12 + .../processors/NotificationsProcessor.test.ts | 390 +++++++++- .../processors/NotificationsProcessor.ts | 305 +++++--- server/queues/tasks/DeliverWebhookTask.ts | 2 + server/routes/api/index.ts | 2 + server/routes/api/subscriptions.test.ts | 702 ++++++++++++++++++ server/routes/api/subscriptions.ts | 141 ++++ ...20722000000-backfill-subscriptions.test.ts | 176 +++++ .../20220722000000-backfill-subscriptions.ts | 51 ++ server/scripts/bootstrap.ts | 3 +- server/test/factories.ts | 26 + server/types.ts | 8 + shared/i18n/locales/en_US/translation.json | 4 + yarn.lock | 8 +- 36 files changed, 2594 insertions(+), 165 deletions(-) create mode 100644 app/models/Subscription.ts create mode 100644 app/stores/SubscriptionsStore.ts create mode 100644 server/commands/subscriptionCreator.test.ts create mode 100644 server/commands/subscriptionCreator.ts create mode 100644 server/commands/subscriptionDestroyer.test.ts create mode 100644 server/commands/subscriptionDestroyer.ts create mode 100644 server/migrations/20220719121200-create-subscriptions.js create mode 100644 server/models/Subscription.ts create mode 100644 server/policies/subscription.ts create mode 100644 server/presenters/subscription.ts create mode 100644 server/routes/api/subscriptions.test.ts create mode 100644 server/routes/api/subscriptions.ts create mode 100644 server/scripts/20220722000000-backfill-subscriptions.test.ts create mode 100644 server/scripts/20220722000000-backfill-subscriptions.ts 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"