feat: Document subscriptions (#3834)

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

View File

@@ -11,6 +11,8 @@ import {
ImportIcon, ImportIcon,
PinIcon, PinIcon,
SearchIcon, SearchIcon,
UnsubscribeIcon,
SubscribeIcon,
MoveIcon, MoveIcon,
TrashIcon, TrashIcon,
CrossIcon, CrossIcon,
@@ -115,6 +117,68 @@ export const unstarDocument = createAction({
}, },
}); });
export const subscribeDocument = createAction({
name: ({ t }) => t("Subscribe"),
section: DocumentSection,
icon: <SubscribeIcon />,
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: <UnsubscribeIcon />,
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({ export const downloadDocument = createAction({
name: ({ t, isContextMenu }) => name: ({ t, isContextMenu }) =>
isContextMenu ? t("Download") : t("Download document"), isContextMenu ? t("Download") : t("Download document"),
@@ -471,6 +535,8 @@ export const rootDocumentActions = [
downloadDocument, downloadDocument,
starDocument, starDocument,
unstarDocument, unstarDocument,
subscribeDocument,
unsubscribeDocument,
duplicateDocument, duplicateDocument,
moveDocument, moveDocument,
permanentlyDeleteDocument, permanentlyDeleteDocument,

View File

@@ -27,6 +27,8 @@ import { actionToMenuItem } from "~/actions";
import { import {
pinDocument, pinDocument,
createTemplate, createTemplate,
subscribeDocument,
unsubscribeDocument,
moveDocument, moveDocument,
deleteDocument, deleteDocument,
permanentlyDeleteDocument, permanentlyDeleteDocument,
@@ -250,9 +252,10 @@ function DocumentMenu({
...restoreItems, ...restoreItems,
], ],
}, },
actionToMenuItem(unstarDocument, context),
actionToMenuItem(starDocument, context), actionToMenuItem(starDocument, context),
actionToMenuItem(pinDocument, context), actionToMenuItem(unstarDocument, context),
actionToMenuItem(subscribeDocument, context),
actionToMenuItem(unsubscribeDocument, context),
{ {
type: "separator", type: "separator",
}, },
@@ -284,6 +287,10 @@ function DocumentMenu({
}, },
actionToMenuItem(archiveDocument, context), actionToMenuItem(archiveDocument, context),
actionToMenuItem(moveDocument, context), actionToMenuItem(moveDocument, context),
actionToMenuItem(pinDocument, context),
{
type: "separator",
},
actionToMenuItem(deleteDocument, context), actionToMenuItem(deleteDocument, context),
actionToMenuItem(permanentlyDeleteDocument, context), actionToMenuItem(permanentlyDeleteDocument, context),
{ {

View File

@@ -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 @computed
get isArchived(): boolean { get isArchived(): boolean {
return !!this.archivedAt; return !!this.archivedAt;
@@ -255,15 +268,15 @@ export default class Document extends ParanoidModel {
}; };
@action @action
pin = async (collectionId?: string) => { pin = (collectionId?: string) => {
await this.store.rootStore.pins.create({ return this.store.rootStore.pins.create({
documentId: this.id, documentId: this.id,
...(collectionId ? { collectionId } : {}), ...(collectionId ? { collectionId } : {}),
}); });
}; };
@action @action
unpin = async (collectionId?: string) => { unpin = (collectionId?: string) => {
const pin = this.store.rootStore.pins.orderedData.find( const pin = this.store.rootStore.pins.orderedData.find(
(pin) => (pin) =>
pin.documentId === this.id && pin.documentId === this.id &&
@@ -271,19 +284,39 @@ export default class Document extends ParanoidModel {
(!collectionId && !pin.collectionId)) (!collectionId && !pin.collectionId))
); );
await pin?.delete(); return pin?.delete();
}; };
@action @action
star = async () => { star = () => {
return this.store.star(this); return this.store.star(this);
}; };
@action @action
unstar = async () => { unstar = () => {
return this.store.unstar(this); 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 @action
view = () => { view = () => {
// we don't record views for documents in the trash // we don't record views for documents in the trash
@@ -304,7 +337,7 @@ export default class Document extends ParanoidModel {
}; };
@action @action
templatize = async () => { templatize = () => {
return this.store.templatize(this.id); return this.store.templatize(this.id);
}; };

View File

@@ -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;

View File

@@ -8,6 +8,7 @@ import ErrorOffline from "~/scenes/ErrorOffline";
import usePolicy from "~/hooks/usePolicy"; import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import { NavigationNode } from "~/types"; import { NavigationNode } from "~/types";
import Logger from "~/utils/Logger";
import { NotFoundError, OfflineError } from "~/utils/errors"; import { NotFoundError, OfflineError } from "~/utils/errors";
import history from "~/utils/history"; import history from "~/utils/history";
import { matchDocumentEdit } from "~/utils/routeHelpers"; import { matchDocumentEdit } from "~/utils/routeHelpers";
@@ -41,7 +42,7 @@ type Props = RouteComponentProps<Params, StaticContext, LocationState> & {
}; };
function DataLoader({ match, children }: Props) { function DataLoader({ match, children }: Props) {
const { ui, shares, documents, auth, revisions } = useStores(); const { ui, shares, documents, auth, revisions, subscriptions } = useStores();
const { team } = auth; const { team } = auth;
const [error, setError] = React.useState<Error | null>(null); const [error, setError] = React.useState<Error | null>(null);
const { revisionId, shareId, documentSlug } = match.params; const { revisionId, shareId, documentSlug } = match.params;
@@ -86,6 +87,22 @@ function DataLoader({ match, children }: Props) {
fetchRevision(); fetchRevision();
}, [revisions, revisionId]); }, [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( const onCreateLink = React.useCallback(
async (title: string) => { async (title: string) => {
if (!document) { if (!document) {

View File

@@ -740,20 +740,37 @@ export default class DocumentsStore extends BaseStore<Document> {
} }
}; };
star = async (document: Document) => { star = (document: Document) => {
await this.rootStore.stars.create({ return this.rootStore.stars.create({
documentId: document.id, documentId: document.id,
}); });
}; };
unstar = async (document: Document) => { unstar = (document: Document) => {
const star = this.rootStore.stars.orderedData.find( const star = this.rootStore.stars.orderedData.find(
(star) => star.documentId === document.id (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)); return find(this.orderedData, (doc) => url.endsWith(doc.urlId));
}; };

View File

@@ -18,6 +18,7 @@ import RevisionsStore from "./RevisionsStore";
import SearchesStore from "./SearchesStore"; import SearchesStore from "./SearchesStore";
import SharesStore from "./SharesStore"; import SharesStore from "./SharesStore";
import StarsStore from "./StarsStore"; import StarsStore from "./StarsStore";
import SubscriptionsStore from "./SubscriptionsStore";
import ToastsStore from "./ToastsStore"; import ToastsStore from "./ToastsStore";
import UiStore from "./UiStore"; import UiStore from "./UiStore";
import UsersStore from "./UsersStore"; import UsersStore from "./UsersStore";
@@ -45,6 +46,7 @@ export default class RootStore {
shares: SharesStore; shares: SharesStore;
ui: UiStore; ui: UiStore;
stars: StarsStore; stars: StarsStore;
subscriptions: SubscriptionsStore;
users: UsersStore; users: UsersStore;
views: ViewsStore; views: ViewsStore;
toasts: ToastsStore; toasts: ToastsStore;
@@ -72,6 +74,7 @@ export default class RootStore {
this.searches = new SearchesStore(this); this.searches = new SearchesStore(this);
this.shares = new SharesStore(this); this.shares = new SharesStore(this);
this.stars = new StarsStore(this); this.stars = new StarsStore(this);
this.subscriptions = new SubscriptionsStore(this);
this.ui = new UiStore(); this.ui = new UiStore();
this.users = new UsersStore(this); this.users = new UsersStore(this);
this.views = new ViewsStore(this); this.views = new ViewsStore(this);
@@ -99,6 +102,7 @@ export default class RootStore {
this.searches.clear(); this.searches.clear();
this.shares.clear(); this.shares.clear();
this.stars.clear(); this.stars.clear();
this.subscriptions.clear();
this.fileOperations.clear(); this.fileOperations.clear();
// this.ui omitted to keep ui settings between sessions // this.ui omitted to keep ui settings between sessions
this.users.clear(); this.users.clear();

View File

@@ -0,0 +1,11 @@
import Subscription from "~/models/Subscription";
import BaseStore, { RPCAction } from "./BaseStore";
import RootStore from "./RootStore";
export default class SubscriptionsStore extends BaseStore<Subscription> {
actions = [RPCAction.List, RPCAction.Create, RPCAction.Delete];
constructor(rootStore: RootStore) {
super(rootStore, Subscription);
}
}

View File

@@ -136,7 +136,7 @@
"natural-sort": "^1.0.0", "natural-sort": "^1.0.0",
"node-fetch": "2.6.7", "node-fetch": "2.6.7",
"nodemailer": "^6.6.1", "nodemailer": "^6.6.1",
"outline-icons": "^1.43.1", "outline-icons": "^1.44.0",
"oy-vey": "^0.11.2", "oy-vey": "^0.11.2",
"passport": "^0.6.0", "passport": "^0.6.0",
"passport-google-oauth2": "^0.2.0", "passport-google-oauth2": "^0.2.0",

View File

@@ -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);
});
});

View File

@@ -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<Subscription> {
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;
}

View File

@@ -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();
});
});

View File

@@ -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<Subscription> {
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;
}

View File

@@ -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");
},
};

View File

@@ -794,10 +794,27 @@ class Document extends ParanoidModel {
return undefined; 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<User>): Promise<User[]> => {
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 * Calculate all of the document ids that are children of this document by
* iterating through parentDocumentId references in the most efficient way. * iterating through parentDocumentId references in the most efficient way.
* *
* @param where query options to further filter the documents
* @param options FindOptions * @param options FindOptions
* @returns A promise that resolves to a list of document ids * @returns A promise that resolves to a list of document ids
*/ */

View File

@@ -11,12 +11,22 @@ import {
IsIn, IsIn,
Default, Default,
DataType, DataType,
Scopes,
} from "sequelize-typescript"; } from "sequelize-typescript";
import env from "@server/env"; import env from "@server/env";
import Team from "./Team"; import Team from "./Team";
import User from "./User"; import User from "./User";
import Fix from "./decorators/Fix"; import Fix from "./decorators/Fix";
@Scopes(() => ({
withUser: {
include: [
{
association: "user",
},
],
},
}))
@Table({ @Table({
tableName: "notification_settings", tableName: "notification_settings",
modelName: "notification_setting", modelName: "notification_setting",

View File

@@ -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;

View File

@@ -53,3 +53,5 @@ export { default as View } from "./View";
export { default as WebhookSubscription } from "./WebhookSubscription"; export { default as WebhookSubscription } from "./WebhookSubscription";
export { default as WebhookDelivery } from "./WebhookDelivery"; export { default as WebhookDelivery } from "./WebhookDelivery";
export { default as Subscription } from "./Subscription";

View File

@@ -151,31 +151,36 @@ allow(User, "move", Document, (user, document) => {
return user.teamId === document.teamId; return user.teamId === document.teamId;
}); });
allow(User, ["pin", "unpin"], Document, (user, document) => { allow(
if (!document) { User,
return false; ["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) => { allow(User, ["pinToHome"], Document, (user, document) => {
if (!document) { if (!document) {

View File

@@ -20,6 +20,7 @@ import "./pins";
import "./searchQuery"; import "./searchQuery";
import "./share"; import "./share";
import "./star"; import "./star";
import "./subscription";
import "./user"; import "./user";
import "./team"; import "./team";
import "./group"; import "./group";

View File

@@ -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;
}
);

View File

@@ -17,6 +17,7 @@ import presentSearchQuery from "./searchQuery";
import presentShare from "./share"; import presentShare from "./share";
import presentSlackAttachment from "./slackAttachment"; import presentSlackAttachment from "./slackAttachment";
import presentStar from "./star"; import presentStar from "./star";
import presentSubscription from "./subscription";
import presentTeam from "./team"; import presentTeam from "./team";
import presentUser from "./user"; import presentUser from "./user";
import presentView from "./view"; import presentView from "./view";
@@ -36,6 +37,7 @@ export {
presentShare, presentShare,
presentSearchQuery, presentSearchQuery,
presentStar, presentStar,
presentSubscription,
presentTeam, presentTeam,
presentGroup, presentGroup,
presentIntegration, presentIntegration,

View File

@@ -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,
};
}

View File

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

View File

@@ -1,4 +1,7 @@
import { uniqBy } from "lodash";
import { Op } from "sequelize"; import { Op } from "sequelize";
import subscriptionCreator from "@server/commands/subscriptionCreator";
import { sequelize } from "@server/database/sequelize";
import CollectionNotificationEmail from "@server/emails/templates/CollectionNotificationEmail"; import CollectionNotificationEmail from "@server/emails/templates/CollectionNotificationEmail";
import DocumentNotificationEmail from "@server/emails/templates/DocumentNotificationEmail"; import DocumentNotificationEmail from "@server/emails/templates/DocumentNotificationEmail";
import Logger from "@server/logging/Logger"; import Logger from "@server/logging/Logger";
@@ -9,12 +12,14 @@ import {
Collection, Collection,
User, User,
NotificationSetting, NotificationSetting,
Subscription,
} from "@server/models"; } from "@server/models";
import { can } from "@server/policies";
import { import {
DocumentEvent,
CollectionEvent, CollectionEvent,
RevisionEvent, RevisionEvent,
Event, Event,
DocumentEvent,
} from "@server/types"; } from "@server/types";
import BaseProcessor from "./BaseProcessor"; import BaseProcessor from "./BaseProcessor";
@@ -44,141 +49,217 @@ export default class NotificationsProcessor extends BaseProcessor {
if (event.data?.source === "import") { if (event.data?.source === "import") {
return; return;
} }
const [collection, document, team] = await Promise.all([ const [collection, document, team] = await Promise.all([
Collection.findByPk(event.collectionId), Collection.findByPk(event.collectionId),
Document.findByPk(event.documentId), Document.findByPk(event.documentId),
Team.findByPk(event.teamId), Team.findByPk(event.teamId),
]); ]);
if (!document || !team || !collection) { if (!document || !team || !collection) {
return; 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) { await this.createDocumentSubscriptions(document, event);
// Suppress notifications for suspended users
if (setting.user.isSuspended) { const recipients = await this.getDocumentNotificationRecipients(
continue; 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) { async collectionCreated(event: CollectionEvent) {
const collection = await Collection.findByPk(event.collectionId, { const collection = await Collection.scope("withUser").findByPk(
include: [ event.collectionId
{ );
model: User,
required: true, if (!collection || !collection.permission) {
as: "user", return;
}, }
],
const recipients = await this.getCollectionNotificationRecipients(
collection,
event.name
);
for (const recipient of recipients) {
// Suppress notifications for suspended users
if (recipient.user.isSuspended || !recipient.user.email) {
continue;
}
await CollectionNotificationEmail.schedule({
to: recipient.user.email,
eventName: "created",
collectionId: collection.id,
unsubscribeUrl: recipient.unsubscribeUrl,
});
}
}
/**
* Create any new subscriptions that might be missing for collaborators in the
* document on publish and revision creation. This does mean that there is a
* short period of time where the user is not subscribed after editing until a
* revision is created.
*
* @param document The document to create subscriptions for
* @param event The event that triggered the subscription creation
*/
private createDocumentSubscriptions = async (
document: Document,
event: DocumentEvent | RevisionEvent
): Promise<void> => {
await sequelize.transaction(async (transaction) => {
const users = await document.collaborators({ transaction });
for (const user of users) {
if (user && can(user, "subscribe", document)) {
await subscriptionCreator({
user,
documentId: document.id,
event: "documents.update",
resubscribe: false,
transaction,
ip: event.ip,
});
}
}
}); });
if (!collection) { };
return;
} /**
if (!collection.permission) { * Get the recipients of a notification for a collection event.
return; *
} * @param collection The collection to get recipients for
const notificationSettings = await NotificationSetting.findAll({ * @param eventName The event name
* @returns A list of recipients
*/
private getCollectionNotificationRecipients = async (
collection: Collection,
eventName: string
): Promise<NotificationSetting[]> => {
// First find all the users that have notifications enabled for this event
// type at all and aren't the one that performed the action.
const recipients = await NotificationSetting.scope("withUser").findAll({
where: { where: {
userId: { userId: {
[Op.ne]: collection.createdById, [Op.ne]: collection.createdById,
}, },
teamId: collection.teamId, teamId: collection.teamId,
event: event.name, event: eventName,
}, },
include: [
{
model: User,
required: true,
as: "user",
},
],
}); });
for (const setting of notificationSettings) { // Ensure we only have one recipient per user as a safety measure
// Suppress notifications for suspended users return uniqBy(recipients, "userId");
if (setting.user.isSuspended || !setting.user.email) { };
continue;
}
await CollectionNotificationEmail.schedule({ /**
to: setting.user.email, * Get the recipients of a notification for a document event.
eventName: "created", *
collectionId: collection.id, * @param document The document to get recipients for
unsubscribeUrl: setting.unsubscribeUrl, * @param eventName The event name
* @returns A list of recipients
*/
private getDocumentNotificationRecipients = async (
document: Document,
eventName: string
): Promise<NotificationSetting[]> => {
// First find all the users that have notifications enabled for this event
// type at all and aren't the one that performed the action.
let recipients = await NotificationSetting.scope("withUser").findAll({
where: {
userId: {
[Op.ne]: document.lastModifiedById,
},
teamId: document.teamId,
event: eventName,
},
});
// If the event is a revision creation we can filter further to only those
// that have a subscription to the document…
if (eventName === "documents.update") {
const subscriptions = await Subscription.findAll({
attributes: ["userId"],
where: {
userId: recipients.map((recipient) => recipient.user.id),
documentId: document.id,
event: eventName,
},
}); });
const subscribedUserIds = subscriptions.map(
(subscription) => subscription.userId
);
recipients = recipients.filter((recipient) =>
subscribedUserIds.includes(recipient.user.id)
);
} }
}
// Ensure we only have one recipient per user as a safety measure
return uniqBy(recipients, "userId");
};
private shouldNotify = async (
document: Document,
user: User
): Promise<boolean> => {
// Suppress notifications for suspended and users with no email address
if (user.isSuspended || !user.email) {
return false;
}
// Check the recipient has access to the collection this document is in. Just
// because they are subscribed doesn't meant they still have access to read
// the document.
const collectionIds = await user.collectionIds();
if (!collectionIds.includes(document.collectionId)) {
return false;
}
// If this recipient has viewed the document since the last update was made
// then we can avoid sending them a useless notification, yay.
const view = await View.findOne({
where: {
userId: user.id,
documentId: document.id,
updatedAt: {
[Op.gt]: document.updatedAt,
},
},
});
if (view) {
Logger.info(
"processor",
`suppressing notification to ${user.id} because update viewed`
);
return false;
}
return true;
};
} }

View File

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

View File

@@ -27,6 +27,7 @@ import revisions from "./revisions";
import searches from "./searches"; import searches from "./searches";
import shares from "./shares"; import shares from "./shares";
import stars from "./stars"; import stars from "./stars";
import subscriptions from "./subscriptions";
import team from "./team"; import team from "./team";
import users from "./users"; import users from "./users";
import views from "./views"; import views from "./views";
@@ -64,6 +65,7 @@ router.use("/", apiKeys.routes());
router.use("/", searches.routes()); router.use("/", searches.routes());
router.use("/", shares.routes()); router.use("/", shares.routes());
router.use("/", stars.routes()); router.use("/", stars.routes());
router.use("/", subscriptions.routes());
router.use("/", team.routes()); router.use("/", team.routes());
router.use("/", integrations.routes()); router.use("/", integrations.routes());
router.use("/", notificationSettings.routes()); router.use("/", notificationSettings.routes());

View File

@@ -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");
});
});

View File

@@ -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;

View File

@@ -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);
});
});

View File

@@ -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);
}

View File

@@ -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 require("../database/sequelize");
process.env.SINGLE_RUN = true;
export {}; export {};

View File

@@ -17,6 +17,7 @@ import {
WebhookSubscription, WebhookSubscription,
WebhookDelivery, WebhookDelivery,
ApiKey, ApiKey,
Subscription,
} from "@server/models"; } from "@server/models";
import { import {
FileOperationState, FileOperationState,
@@ -88,6 +89,31 @@ export async function buildStar(overrides: Partial<Star> = {}) {
}); });
} }
export async function buildSubscription(overrides: Partial<Subscription> = {}) {
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<string, any> = {}) { export function buildTeam(overrides: Record<string, any> = {}) {
count++; count++;
return Team.create( return Team.create(

View File

@@ -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 & { export type ViewEvent = BaseEvent & {
name: "views.create"; name: "views.create";
documentId: string; documentId: string;
@@ -293,6 +300,7 @@ export type Event =
| GroupEvent | GroupEvent
| RevisionEvent | RevisionEvent
| ShareEvent | ShareEvent
| SubscriptionEvent
| TeamEvent | TeamEvent
| UserEvent | UserEvent
| ViewEvent | ViewEvent

View File

@@ -10,6 +10,10 @@
"Developer": "Developer", "Developer": "Developer",
"Open document": "Open document", "Open document": "Open document",
"New document": "New 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": "Download",
"Download document": "Download document", "Download document": "Download document",
"Duplicate": "Duplicate", "Duplicate": "Duplicate",

View File

@@ -11315,10 +11315,10 @@ os-browserify@^0.3.0:
resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27" resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27"
integrity sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc= integrity sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc=
outline-icons@^1.43.1: outline-icons@^1.44.0:
version "1.43.1" version "1.44.0"
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.43.1.tgz#3193c4c659c66b34788db043bb2f843b9c437a48" resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.44.0.tgz#ce9fd272f0556db9b05b5615f87c12b16bd18ea0"
integrity sha512-REj+JsCFi2Jv5uG0/OrBsMVSBFAIsSROxynWbuO9r2eNT8wdqjni02Mk1gq1qFfTbwOvHJ+7ycadu6zlISAK2g== integrity sha512-nkKGXuGbOgZjPkyVpZZu7CIDrfmt2eER+3RWfE1LU/GqHkuUt0c5JpCsEyhxXAPMUW09q4sDvHjLVge7DUWeYg==
oy-vey@^0.11.2: oy-vey@^0.11.2:
version "0.11.2" version "0.11.2"