feat: Document subscriptions (#3834)
Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
committed by
GitHub
parent
864f585e5b
commit
24c71c38a5
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
29
app/models/Subscription.ts
Normal file
29
app/models/Subscription.ts
Normal 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;
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
11
app/stores/SubscriptionsStore.ts
Normal file
11
app/stores/SubscriptionsStore.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
275
server/commands/subscriptionCreator.test.ts
Normal file
275
server/commands/subscriptionCreator.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
74
server/commands/subscriptionCreator.ts
Normal file
74
server/commands/subscriptionCreator.ts
Normal 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;
|
||||||
|
}
|
||||||
101
server/commands/subscriptionDestroyer.test.ts
Normal file
101
server/commands/subscriptionDestroyer.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
41
server/commands/subscriptionDestroyer.ts
Normal file
41
server/commands/subscriptionDestroyer.ts
Normal 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;
|
||||||
|
}
|
||||||
70
server/migrations/20220719121200-create-subscriptions.js
Normal file
70
server/migrations/20220719121200-create-subscriptions.js
Normal 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");
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
46
server/models/Subscription.ts
Normal file
46
server/models/Subscription.ts
Normal 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;
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
21
server/policies/subscription.ts
Normal file
21
server/policies/subscription.ts
Normal 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;
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -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,
|
||||||
|
|||||||
12
server/presenters/subscription.ts
Normal file
12
server/presenters/subscription.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
702
server/routes/api/subscriptions.test.ts
Normal file
702
server/routes/api/subscriptions.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
141
server/routes/api/subscriptions.ts
Normal file
141
server/routes/api/subscriptions.ts
Normal 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;
|
||||||
176
server/scripts/20220722000000-backfill-subscriptions.test.ts
Normal file
176
server/scripts/20220722000000-backfill-subscriptions.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
51
server/scripts/20220722000000-backfill-subscriptions.ts
Normal file
51
server/scripts/20220722000000-backfill-subscriptions.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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 {};
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user