feat: Collection subscription (#8392)

* feat: Collection subscription

* refactor to use latest impl

* load subscriptions only once

* tests, type rename, migration index

* all users in publish flow

* tsc

* remove SubscriptionType.Collection enum

* review
This commit is contained in:
Hemachandar
2025-02-22 20:23:19 +05:30
committed by GitHub
parent ae2fafac0e
commit 6a1f2399db
30 changed files with 759 additions and 151 deletions

View File

@@ -8,8 +8,10 @@ import {
SearchIcon,
ShapesIcon,
StarredIcon,
SubscribeIcon,
TrashIcon,
UnstarredIcon,
UnsubscribeIcon,
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
@@ -205,6 +207,66 @@ export const unstarCollection = createAction({
},
});
export const subscribeCollection = createAction({
name: ({ t }) => t("Subscribe"),
analyticsName: "Subscribe to collection",
section: ActiveCollectionSection,
icon: <SubscribeIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!collection?.isSubscribed &&
stores.policies.abilities(activeCollectionId).subscribe
);
},
perform: async ({ activeCollectionId, stores, t }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
await collection?.subscribe();
toast.success(t("Subscribed to document notifications"));
},
});
export const unsubscribeCollection = createAction({
name: ({ t }) => t("Unsubscribe"),
analyticsName: "Unsubscribe from collection",
section: ActiveCollectionSection,
icon: <UnsubscribeIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!!collection?.isSubscribed &&
stores.policies.abilities(activeCollectionId).unsubscribe
);
},
perform: async ({ activeCollectionId, currentUserId, stores, t }) => {
if (!activeCollectionId || !currentUserId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
await collection?.unsubscribe();
toast.success(t("Unsubscribed from document notifications"));
},
});
export const archiveCollection = createAction({
name: ({ t }) => `${t("Archive")}`,
analyticsName: "Archive collection",
@@ -331,5 +393,7 @@ export const rootCollectionActions = [
createCollection,
starCollection,
unstarCollection,
subscribeCollection,
unsubscribeCollection,
deleteCollection,
];

View File

@@ -333,6 +333,7 @@ export const subscribeDocument = createAction({
const document = stores.documents.get(activeDocumentId);
return (
!document?.collection?.isSubscribed &&
!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).subscribe
);
@@ -361,8 +362,9 @@ export const unsubscribeDocument = createAction({
const document = stores.documents.get(activeDocumentId);
return (
!!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).unsubscribe
!!document?.collection?.isSubscribed ||
(!!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).unsubscribe)
);
},
perform: async ({ activeDocumentId, stores, currentUserId, t }) => {

View File

@@ -20,6 +20,7 @@ import {
MenuHeading,
MenuItem as TMenuItem,
} from "~/types";
import Tooltip from "../Tooltip";
import Header from "./Header";
import MenuItem, { MenuAnchor } from "./MenuItem";
import MouseSafeArea from "./MouseSafeArea";
@@ -167,7 +168,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
}
if (item.type === "button") {
return (
const menuItem = (
<MenuItem
as="button"
id={`${item.title}-${index}`}
@@ -182,6 +183,14 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
{item.title}
</MenuItem>
);
return item.tooltip ? (
<Tooltip content={item.tooltip} placement={"bottom"}>
<div>{menuItem}</div>
</Tooltip>
) : (
<>{menuItem}</>
);
}
if (item.type === "submenu") {

View File

@@ -14,6 +14,7 @@ import { useHistory } from "react-router-dom";
import { useMenuState, MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import { toast } from "sonner";
import { SubscriptionType } from "@shared/types";
import { getEventFiles } from "@shared/utils/files";
import Collection from "~/models/Collection";
import ContextMenu, { Placement } from "~/components/ContextMenu";
@@ -31,10 +32,13 @@ import {
createTemplate,
archiveCollection,
restoreCollection,
subscribeCollection,
unsubscribeCollection,
} from "~/actions/definitions/collections";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { MenuItem } from "~/types";
import { newDocumentPath } from "~/utils/routeHelpers";
@@ -63,11 +67,28 @@ function CollectionMenu({
placement,
});
const team = useCurrentTeam();
const { documents, dialogs } = useStores();
const { documents, dialogs, subscriptions } = useStores();
const { t } = useTranslation();
const history = useHistory();
const file = React.useRef<HTMLInputElement>(null);
const {
loading: subscriptionLoading,
loaded: subscriptionLoaded,
request: loadSubscription,
} = useRequest(() =>
subscriptions.fetchOne({
collectionId: collection.id,
event: SubscriptionType.Document,
})
);
const handlePointerEnter = React.useCallback(() => {
if (!subscriptionLoading && !subscriptionLoaded) {
void loadSubscription();
}
}, [subscriptionLoading, subscriptionLoaded, loadSubscription]);
const handleExport = React.useCallback(() => {
dialogs.openModal({
title: t("Export collection"),
@@ -157,6 +178,8 @@ function CollectionMenu({
actionToMenuItem(restoreCollection, context),
actionToMenuItem(starCollection, context),
actionToMenuItem(unstarCollection, context),
actionToMenuItem(subscribeCollection, context),
actionToMenuItem(unsubscribeCollection, context),
{
type: "separator",
},
@@ -272,9 +295,15 @@ function CollectionMenu({
</label>
</VisuallyHidden>
{label ? (
<MenuButton {...menu}>{label}</MenuButton>
<MenuButton {...menu} onPointerEnter={handlePointerEnter}>
{label}
</MenuButton>
) : (
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
<OverflowMenuButton
aria-label={t("Show menu")}
{...menu}
onPointerEnter={handlePointerEnter}
/>
)}
<ContextMenu
{...menu}

View File

@@ -1,5 +1,6 @@
import capitalize from "lodash/capitalize";
import isEmpty from "lodash/isEmpty";
import noop from "lodash/noop";
import { observer } from "mobx-react";
import { EditIcon, InputIcon, RestoreIcon, SearchIcon } from "outline-icons";
import * as React from "react";
@@ -11,7 +12,7 @@ import { toast } from "sonner";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import { UserPreference } from "@shared/types";
import { SubscriptionType, UserPreference } from "@shared/types";
import { getEventFiles } from "@shared/utils/files";
import Document from "~/models/Document";
import ContextMenu from "~/components/ContextMenu";
@@ -56,7 +57,7 @@ import useMobile from "~/hooks/useMobile";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { MenuItem } from "~/types";
import { MenuItem, MenuItemButton } from "~/types";
import { documentEditPath } from "~/utils/routeHelpers";
import { MenuContext, useMenuContext } from "./MenuContext";
@@ -102,8 +103,14 @@ const MenuTrigger: React.FC<MenuTriggerProps> = ({ label, onTrigger }) => {
Promise.all([
subscriptions.fetchOne({
documentId: document.id,
event: "documents.update",
event: SubscriptionType.Document,
}),
document.collectionId
? subscriptions.fetchOne({
collectionId: document.collectionId,
event: SubscriptionType.Document,
})
: noop,
pins.fetchOne({
documentId: document.id,
collectionId: document.collectionId ?? null,
@@ -254,8 +261,20 @@ const MenuContent: React.FC<MenuContentProps> = observer(function MenuContent_({
},
actionToMenuItem(starDocument, context),
actionToMenuItem(unstarDocument, context),
actionToMenuItem(subscribeDocument, context),
actionToMenuItem(unsubscribeDocument, context),
{
...actionToMenuItem(subscribeDocument, context),
disabled: collection?.isSubscribed,
tooltip: collection?.isSubscribed
? t("Subscription inherited from collection")
: undefined,
} as MenuItemButton,
{
...actionToMenuItem(unsubscribeDocument, context),
disabled: collection?.isSubscribed,
tooltip: collection?.isSubscribed
? t("Subscription inherited from collection")
: undefined,
} as MenuItemButton,
{
type: "button",
title: `${t("Find and replace")}`,

View File

@@ -129,6 +129,16 @@ export default class Collection extends ParanoidModel {
);
}
/**
* Returns whether there is a subscription for this collection in the store.
*
* @returns True if there is a subscription, false otherwise.
*/
@computed
get isSubscribed(): boolean {
return !!this.store.rootStore.subscriptions.getByCollectionId(this.id);
}
@computed
get isManualSort(): boolean {
return this.sort.field === "index";
@@ -376,6 +386,22 @@ export default class Collection extends ParanoidModel {
@action
unstar = async () => this.store.unstar(this);
/**
* Subscribes the current user to this collection.
*
* @returns A promise that resolves when the subscription is created.
*/
@action
subscribe = () => this.store.subscribe(this);
/**
* Unsubscribes the current user from this collection.
*
* @returns A promise that resolves when the subscription is destroyed.
*/
@action
unsubscribe = () => this.store.unsubscribe(this);
archive = () => this.store.archive(this);
restore = () => this.store.restore(this);

View File

@@ -1,4 +1,5 @@
import { observable } from "mobx";
import Collection from "./Collection";
import Document from "./Document";
import User from "./User";
import Model from "./base/Model";
@@ -25,6 +26,13 @@ class Subscription extends Model {
@Relation(() => Document, { onDelete: "cascade" })
document?: Document;
/** The collection ID being subscribed to */
collectionId: string;
/** The collection being subscribed to */
@Relation(() => Collection, { onDelete: "cascade" })
collection?: Collection;
/** The event being subscribed to */
@Field
@observable

View File

@@ -8,6 +8,7 @@ import {
CollectionPermission,
CollectionStatusFilter,
FileOperationFormat,
SubscriptionType,
} from "@shared/types";
import Collection from "~/models/Collection";
import { PaginationParams, Properties } from "~/types";
@@ -213,6 +214,20 @@ export default class CollectionsStore extends Store<Collection> {
await star?.delete();
};
subscribe = (collection: Collection) =>
this.rootStore.subscriptions.create({
collectionId: collection.id,
event: SubscriptionType.Document,
});
unsubscribe = (collection: Collection) => {
const subscription = this.rootStore.subscriptions.getByCollectionId(
collection.id
);
return subscription?.delete();
};
@computed
get navigationNodes() {
return this.orderedData.map((collection) => collection.asNavigationNode);

View File

@@ -5,11 +5,12 @@ import find from "lodash/find";
import omitBy from "lodash/omitBy";
import orderBy from "lodash/orderBy";
import { observable, action, computed, runInAction } from "mobx";
import type {
DateFilter,
NavigationNode,
PublicTeam,
StatusFilter,
import {
SubscriptionType,
type DateFilter,
type NavigationNode,
type PublicTeam,
type StatusFilter,
} from "@shared/types";
import { subtractDate } from "@shared/utils/date";
import { bytesToHumanReadable } from "@shared/utils/files";
@@ -817,7 +818,7 @@ export default class DocumentsStore extends Store<Document> {
subscribe = (document: Document) =>
this.rootStore.subscriptions.create({
documentId: document.id,
event: "documents.update",
event: SubscriptionType.Document,
});
unsubscribe = (document: Document) => {

View File

@@ -1,5 +1,6 @@
import invariant from "invariant";
import { action } from "mobx";
import { SubscriptionType } from "@shared/types";
import Subscription from "~/models/Subscription";
import { client } from "~/utils/ApiClient";
import { AuthorizationError, NotFoundError } from "~/utils/errors";
@@ -14,8 +15,16 @@ export default class SubscriptionsStore extends Store<Subscription> {
}
@action
async fetchOne({ documentId, event }: { documentId: string; event: string }) {
const subscription = this.getByDocumentId(documentId);
async fetchOne(
options: { event: SubscriptionType } & (
| { documentId: string }
| { collectionId: string }
)
) {
const subscription =
"collectionId" in options
? this.getByCollectionId(options.collectionId)
: this.getByDocumentId(options.documentId);
if (subscription) {
return subscription;
@@ -24,10 +33,7 @@ export default class SubscriptionsStore extends Store<Subscription> {
this.isFetching = true;
try {
const res = await client.post(`/${this.apiEndpoint}.info`, {
documentId,
event,
});
const res = await client.post(`/${this.apiEndpoint}.info`, options);
invariant(res?.data, "Data should be available");
return this.add(res.data);
} catch (err) {
@@ -42,4 +48,7 @@ export default class SubscriptionsStore extends Store<Subscription> {
getByDocumentId = (documentId: string): Subscription | undefined =>
this.find({ documentId });
getByCollectionId = (collectionId: string): Subscription | undefined =>
this.find({ collectionId });
}

View File

@@ -27,6 +27,7 @@ export type MenuItemButton = {
selected?: boolean;
disabled?: boolean;
icon?: React.ReactNode;
tooltip?: React.ReactChild;
};
export type MenuItemWithChildren = {

View File

@@ -1,14 +1,51 @@
import { SubscriptionType } from "@shared/types";
import { createContext } from "@server/context";
import { Subscription, Event } from "@server/models";
import { sequelize } from "@server/storage/database";
import { buildDocument, buildUser } from "@server/test/factories";
import {
buildCollection,
buildDocument,
buildUser,
} from "@server/test/factories";
import subscriptionCreator from "./subscriptionCreator";
describe("subscriptionCreator", () => {
const ip = "127.0.0.1";
const subscribedEvent = "documents.update";
const subscribedEvent = SubscriptionType.Document;
it("should create a subscription", async () => {
it("should create a document subscription for the whole collection", async () => {
const user = await buildUser();
const collection = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
const subscription = await sequelize.transaction(async (transaction) =>
subscriptionCreator({
ctx: createContext({ user, transaction, ip }),
collectionId: collection.id,
event: SubscriptionType.Document,
})
);
const event = await Event.findOne({
where: {
teamId: user.teamId,
},
});
expect(subscription.collectionId).toEqual(collection.id);
expect(subscription.documentId).toBeNull();
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?.collectionId).toEqual(subscription.collectionId);
});
it("should create a document subscription", async () => {
const user = await buildUser();
const document = await buildDocument({
@@ -31,6 +68,7 @@ describe("subscriptionCreator", () => {
});
expect(subscription.documentId).toEqual(document.id);
expect(subscription.collectionId).toBeNull();
expect(subscription.userId).toEqual(user.id);
expect(event?.name).toEqual("subscriptions.create");
expect(event?.modelId).toEqual(subscription.id);

View File

@@ -1,3 +1,5 @@
import { WhereOptions } from "sequelize";
import { SubscriptionType } from "@shared/types";
import { createContext } from "@server/context";
import { Subscription, Document } from "@server/models";
import { sequelize } from "@server/storage/database";
@@ -8,8 +10,10 @@ type Props = {
ctx: APIContext;
/** The document to subscribe to */
documentId?: string;
/** The collection to subscribe to */
collectionId?: string;
/** Event to subscribe to */
event: string;
event: SubscriptionType;
/** Whether the subscription should be restored if it exists in a deleted state */
resubscribe?: boolean;
};
@@ -22,16 +26,27 @@ type Props = {
export default async function subscriptionCreator({
ctx,
documentId,
collectionId,
event,
resubscribe = true,
}: Props): Promise<Subscription> {
const { user } = ctx.context.auth;
const where: WhereOptions<Subscription> = {
userId: user.id,
event,
};
if (documentId) {
where.documentId = documentId;
}
if (collectionId) {
where.collectionId = collectionId;
}
const [subscription] = await Subscription.findOrCreateWithCtx(ctx, {
where: {
userId: user.id,
documentId,
event,
},
where,
paranoid: false, // Previous subscriptions are soft-deleted, we want to know about them here.
});
@@ -68,7 +83,7 @@ export const createSubscriptionsForDocument = async (
transaction,
}),
documentId: document.id,
event: "documents.update",
event: SubscriptionType.Document,
resubscribe: false,
});
}

View File

@@ -0,0 +1,44 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.addColumn(
"subscriptions",
"collectionId",
{
type: Sequelize.UUID,
allowNull: true,
onDelete: "cascade",
references: {
model: "collections",
},
},
{ transaction }
);
await queryInterface.addIndex(
"subscriptions",
["userId", "collectionId", "event"],
{
name: "subscriptions_user_id_collection_id_event",
type: "UNIQUE",
transaction,
}
);
});
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.removeIndex(
"subscriptions",
["userId", "collectionId", "event"],
{ transaction }
);
await queryInterface.removeColumn("subscriptions", "collectionId", {
transaction,
});
});
},
};

View File

@@ -8,6 +8,8 @@ import {
IsIn,
Scopes,
} from "sequelize-typescript";
import { SubscriptionType } from "@shared/types";
import Collection from "./Collection";
import Document from "./Document";
import User from "./User";
import ParanoidModel from "./base/ParanoidModel";
@@ -42,7 +44,14 @@ class Subscription extends ParanoidModel<
@Column(DataType.UUID)
documentId: string | null;
@IsIn([["documents.update"]])
@BelongsTo(() => Collection, "collectionId")
collection: Collection | null;
@ForeignKey(() => Document)
@Column(DataType.UUID)
collectionId: string | null;
@IsIn([Object.values(SubscriptionType)])
@Column(DataType.STRING)
event: string;
}

View File

@@ -0,0 +1,152 @@
import { NotificationEventType } from "@shared/types";
import {
buildDocument,
buildSubscription,
buildUser,
} from "@server/test/factories";
import NotificationHelper from "./NotificationHelper";
describe("NotificationHelper", () => {
describe("getDocumentNotificationRecipients", () => {
it("should return all users who have notification enabled for the event", async () => {
const documentAuthor = await buildUser();
const document = await buildDocument({
userId: documentAuthor.id,
teamId: documentAuthor.teamId,
});
const notificationEnabledUser = await buildUser({
teamId: document.teamId,
notificationSettings: { [NotificationEventType.UpdateDocument]: true },
});
const recipients =
await NotificationHelper.getDocumentNotificationRecipients({
document,
notificationType: NotificationEventType.UpdateDocument,
onlySubscribers: false,
actorId: documentAuthor.id,
});
expect(recipients.length).toEqual(1);
expect(recipients[0].id).toEqual(notificationEnabledUser.id);
});
it("should return users who have subscribed to the document", async () => {
const documentAuthor = await buildUser();
const document = await buildDocument({
userId: documentAuthor.id,
teamId: documentAuthor.teamId,
});
const subscribedUser = await buildUser({ teamId: document.teamId });
await buildSubscription({
userId: subscribedUser.id,
documentId: document.id,
});
const recipients =
await NotificationHelper.getDocumentNotificationRecipients({
document,
notificationType: NotificationEventType.UpdateDocument,
onlySubscribers: true,
actorId: documentAuthor.id,
});
expect(recipients.length).toEqual(1);
expect(recipients[0].id).toEqual(subscribedUser.id);
});
it("should return users who have subscribed to the collection", async () => {
const documentAuthor = await buildUser();
const document = await buildDocument({
userId: documentAuthor.id,
teamId: documentAuthor.teamId,
});
const subscribedUser = await buildUser({ teamId: document.teamId });
await buildSubscription({
userId: subscribedUser.id,
collectionId: document.collectionId!,
});
const recipients =
await NotificationHelper.getDocumentNotificationRecipients({
document,
notificationType: NotificationEventType.UpdateDocument,
onlySubscribers: true,
actorId: documentAuthor.id,
});
expect(recipients.length).toEqual(1);
expect(recipients[0].id).toEqual(subscribedUser.id);
});
it("should return users who have subscribed to either the document or the containing collection", async () => {
const documentAuthor = await buildUser();
const document = await buildDocument({
userId: documentAuthor.id,
teamId: documentAuthor.teamId,
});
const [documentSubscribedUser, collectionSubscribedUser] =
await Promise.all([
buildUser({
teamId: document.teamId,
}),
buildUser({
teamId: document.teamId,
}),
]);
await Promise.all([
buildSubscription({
userId: documentSubscribedUser.id,
documentId: document.id,
}),
buildSubscription({
userId: collectionSubscribedUser.id,
collectionId: document.collectionId!,
}),
]);
const recipients =
await NotificationHelper.getDocumentNotificationRecipients({
document,
notificationType: NotificationEventType.UpdateDocument,
onlySubscribers: true,
actorId: documentAuthor.id,
});
expect(recipients.length).toEqual(2);
const recipientIds = recipients.map((u) => u.id);
expect(recipientIds).toContain(collectionSubscribedUser.id);
expect(recipientIds).toContain(documentSubscribedUser.id);
});
it("should not return suspended users", async () => {
const documentAuthor = await buildUser();
const document = await buildDocument({
userId: documentAuthor.id,
teamId: documentAuthor.teamId,
});
const notificationEnabledUser = await buildUser({
teamId: document.teamId,
notificationSettings: { [NotificationEventType.UpdateDocument]: true },
});
// suspended user
await buildUser({
suspendedAt: new Date(),
teamId: document.teamId,
notificationSettings: { [NotificationEventType.UpdateDocument]: true },
});
const recipients =
await NotificationHelper.getDocumentNotificationRecipients({
document,
notificationType: NotificationEventType.UpdateDocument,
onlySubscribers: false,
actorId: documentAuthor.id,
});
expect(recipients.length).toEqual(1);
expect(recipients[0].id).toEqual(notificationEnabledUser.id);
});
});
});

View File

@@ -1,6 +1,10 @@
import uniq from "lodash/uniq";
import { Op } from "sequelize";
import { NotificationEventType, MentionType } from "@shared/types";
import {
NotificationEventType,
MentionType,
SubscriptionType,
} from "@shared/types";
import Logger from "@server/logging/Logger";
import {
User,
@@ -56,12 +60,12 @@ export default class NotificationHelper {
comment: Comment,
actorId: string
): Promise<User[]> => {
let recipients = await this.getDocumentNotificationRecipients(
let recipients = await this.getDocumentNotificationRecipients({
document,
NotificationEventType.UpdateDocument,
notificationType: NotificationEventType.UpdateDocument,
onlySubscribers: !comment.parentCommentId,
actorId,
!comment.parentCommentId
);
});
recipients = recipients.filter((recipient) =>
recipient.subscribedToEventType(NotificationEventType.CreateComment)
@@ -127,18 +131,22 @@ export default class NotificationHelper {
* Get the recipients of a notification for a document event.
*
* @param document The document to get recipients for.
* @param eventType The event name.
* @param notificationType The notification type for which to find the recipients.
* @param onlySubscribers Whether to consider only the users who have active subscription to the document.
* @param actorId The id of the user that performed the action.
* @param onlySubscribers Whether to only return recipients that are actively
* subscribed to the document.
* @returns A list of recipients
*/
public static getDocumentNotificationRecipients = async (
document: Document,
eventType: NotificationEventType,
actorId: string,
onlySubscribers: boolean
): Promise<User[]> => {
public static getDocumentNotificationRecipients = async ({
document,
notificationType,
onlySubscribers,
actorId,
}: {
document: Document;
notificationType: NotificationEventType;
onlySubscribers: boolean;
actorId: string;
}): Promise<User[]> => {
// 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 User.findAll({
@@ -151,7 +159,7 @@ export default class NotificationHelper {
});
recipients = recipients.filter((recipient) =>
recipient.subscribedToEventType(eventType)
recipient.subscribedToEventType(notificationType)
);
// Filter further to only those that have a subscription to the document…
@@ -160,8 +168,11 @@ export default class NotificationHelper {
attributes: ["userId"],
where: {
userId: recipients.map((recipient) => recipient.id),
documentId: document.id,
event: eventType,
event: SubscriptionType.Document,
[Op.or]: [
{ collectionId: document.collectionId },
{ documentId: document.id },
],
},
});

View File

@@ -49,7 +49,7 @@ allow(User, "read", Collection, (user, collection) => {
allow(
User,
["readDocument", "star", "unstar"],
["readDocument", "star", "unstar", "subscribe", "unsubscribe"],
Collection,
(user, collection) => {
if (!collection || user.teamId !== collection.teamId) {

View File

@@ -5,6 +5,7 @@ export default function presentSubscription(subscription: Subscription) {
id: subscription.id,
userId: subscription.userId,
documentId: subscription.documentId,
collectionId: subscription.collectionId,
event: subscription.event,
createdAt: subscription.createdAt,
updatedAt: subscription.updatedAt,

View File

@@ -1,4 +1,8 @@
import { MentionType, NotificationEventType } from "@shared/types";
import {
MentionType,
NotificationEventType,
SubscriptionType,
} from "@shared/types";
import subscriptionCreator from "@server/commands/subscriptionCreator";
import { createContext } from "@server/context";
import { Comment, Document, Notification, User } from "@server/models";
@@ -34,7 +38,7 @@ export default class CommentCreatedNotificationsTask extends BaseTask<CommentEve
transaction,
}),
documentId: document.id,
event: "documents.update",
event: SubscriptionType.Document,
resubscribe: false,
});
});

View File

@@ -51,12 +51,12 @@ export default class DocumentPublishedNotificationsTask extends BaseTask<Documen
}
const recipients = (
await NotificationHelper.getDocumentNotificationRecipients(
await NotificationHelper.getDocumentNotificationRecipients({
document,
NotificationEventType.PublishDocument,
document.lastModifiedById,
false
)
notificationType: NotificationEventType.PublishDocument,
onlySubscribers: false,
actorId: document.lastModifiedById,
})
).filter((recipient) => !userIdsMentioned.includes(recipient.id));
for (const recipient of recipients) {

View File

@@ -1,4 +1,5 @@
import { Transaction } from "sequelize";
import { SubscriptionType } from "@shared/types";
import subscriptionCreator from "@server/commands/subscriptionCreator";
import { createContext } from "@server/context";
import { Subscription, User } from "@server/models";
@@ -34,7 +35,7 @@ export default class DocumentSubscriptionTask extends BaseTask<DocumentUserEvent
transaction,
}),
documentId: event.documentId,
event: "documents.update",
event: SubscriptionType.Document,
resubscribe: false,
});
});

View File

@@ -73,12 +73,12 @@ export default class RevisionCreatedNotificationsTask extends BaseTask<RevisionE
}
const recipients = (
await NotificationHelper.getDocumentNotificationRecipients(
await NotificationHelper.getDocumentNotificationRecipients({
document,
NotificationEventType.UpdateDocument,
document.lastModifiedById,
true
)
notificationType: NotificationEventType.UpdateDocument,
onlySubscribers: true,
actorId: document.lastModifiedById,
})
).filter((recipient) => !userIdsMentioned.includes(recipient.id));
if (!recipients.length) {
return;

View File

@@ -1,36 +1,38 @@
import isEmpty from "lodash/isEmpty";
import { z } from "zod";
import { SubscriptionType } from "@shared/types";
import { ValidateDocumentId } from "@server/validation";
import { BaseSchema } from "../schema";
const SubscriptionBody = z
.object({
event: z.literal(SubscriptionType.Document),
collectionId: z.string().uuid().optional(),
documentId: z
.string()
.refine(ValidateDocumentId.isValid, {
message: ValidateDocumentId.message,
})
.optional(),
})
.refine((obj) => !(isEmpty(obj.collectionId) && isEmpty(obj.documentId)), {
message: "one of collectionId or documentId is required",
});
export const SubscriptionsListSchema = BaseSchema.extend({
body: z.object({
documentId: z.string().refine(ValidateDocumentId.isValid, {
message: ValidateDocumentId.message,
}),
event: z.literal("documents.update"),
}),
body: SubscriptionBody,
});
export type SubscriptionsListReq = z.infer<typeof SubscriptionsListSchema>;
export const SubscriptionsInfoSchema = BaseSchema.extend({
body: z.object({
documentId: z.string().refine(ValidateDocumentId.isValid, {
message: ValidateDocumentId.message,
}),
event: z.literal("documents.update"),
}),
body: SubscriptionBody,
});
export type SubscriptionsInfoReq = z.infer<typeof SubscriptionsInfoSchema>;
export const SubscriptionsCreateSchema = BaseSchema.extend({
body: z.object({
documentId: z.string().refine(ValidateDocumentId.isValid, {
message: ValidateDocumentId.message,
}),
event: z.literal("documents.update"),
}),
body: SubscriptionBody,
});
export type SubscriptionsCreateReq = z.infer<typeof SubscriptionsCreateSchema>;

View File

@@ -1,15 +1,41 @@
import { SubscriptionType } from "@shared/types";
import { Event } from "@server/models";
import {
buildUser,
buildSubscription,
buildDocument,
buildCollection,
} from "@server/test/factories";
import { getTestServer } from "@server/test/support";
const server = getTestServer();
describe("#subscriptions.create", () => {
it("should create a subscription", async () => {
it("should create a document subscription for the whole collection", async () => {
const user = await buildUser();
const collection = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
const res = await server.post("/api/subscriptions.create", {
body: {
token: user.getJwtToken(),
collectionId: collection.id,
event: SubscriptionType.Document,
},
});
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.collectionId).toEqual(collection.id);
});
it("should create a document subscription", async () => {
const user = await buildUser();
const document = await buildDocument({
@@ -21,7 +47,7 @@ describe("#subscriptions.create", () => {
body: {
token: user.getJwtToken(),
documentId: document.id,
event: "documents.update",
event: SubscriptionType.Document,
},
});
@@ -45,7 +71,7 @@ describe("#subscriptions.create", () => {
body: {
token: user.getJwtToken(),
documentId: document.id,
event: "documents.update",
event: SubscriptionType.Document,
},
});
@@ -79,7 +105,7 @@ describe("#subscriptions.create", () => {
body: {
token: user.getJwtToken(),
documentId: document.id,
event: "documents.update",
event: SubscriptionType.Document,
},
});
@@ -88,7 +114,7 @@ describe("#subscriptions.create", () => {
body: {
token: user.getJwtToken(),
documentId: document.id,
event: "documents.update",
event: SubscriptionType.Document,
},
});
@@ -97,17 +123,16 @@ describe("#subscriptions.create", () => {
body: {
token: user.getJwtToken(),
documentId: document.id,
event: "documents.update",
event: SubscriptionType.Document,
},
});
// List subscriptions associated with
// `document.id`
// List subscriptions associated with `document.id`
const res = await server.post("/api/subscriptions.list", {
body: {
token: user.getJwtToken(),
documentId: document.id,
event: "documents.update",
event: SubscriptionType.Document,
},
});
@@ -132,8 +157,7 @@ describe("#subscriptions.create", () => {
body: {
token: user.getJwtToken(),
documentId: document.id,
// Subscription on event
// that cannot be subscribed to.
// Subscription on event that cannot be subscribed to.
event: "documents.publish",
},
});
@@ -147,10 +171,62 @@ describe("#subscriptions.create", () => {
`event: Invalid literal value, expected "documents.update"`
);
});
it("should throw 400 when neither documentId nor collectionId is provided", async () => {
const user = await buildUser();
const res = await server.post("/api/subscriptions.create", {
body: {
token: user.getJwtToken(),
event: SubscriptionType.Document,
},
});
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(
"body: one of collectionId or documentId is required"
);
});
});
describe("#subscriptions.info", () => {
it("should provide info about a subscription", async () => {
it("should provide info about a document subscription for the collection", async () => {
const user = await buildUser();
const subscriber = await buildUser({ teamId: user.teamId });
const collection = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
await server.post("/api/subscriptions.create", {
body: {
token: subscriber.getJwtToken(),
collectionId: collection.id,
event: SubscriptionType.Document,
},
});
const res = await server.post("/api/subscriptions.info", {
body: {
token: subscriber.getJwtToken(),
collectionId: collection.id,
event: SubscriptionType.Document,
},
});
const subscription = await res.json();
expect(res.status).toEqual(200);
expect(subscription.data.id).toBeDefined();
expect(subscription.data.userId).toEqual(subscriber.id);
expect(subscription.data.collectionId).toEqual(collection.id);
});
it("should provide info about a document subscription", async () => {
const creator = await buildUser();
const subscriber = await buildUser({ teamId: creator.teamId });
@@ -171,7 +247,7 @@ describe("#subscriptions.info", () => {
body: {
token: subscriber.getJwtToken(),
documentId: document0.id,
event: "documents.update",
event: SubscriptionType.Document,
},
});
@@ -180,7 +256,7 @@ describe("#subscriptions.info", () => {
body: {
token: subscriber.getJwtToken(),
documentId: document1.id,
event: "documents.update",
event: SubscriptionType.Document,
},
});
@@ -190,7 +266,7 @@ describe("#subscriptions.info", () => {
body: {
token: subscriber.getJwtToken(),
documentId: document0.id,
event: "documents.update",
event: SubscriptionType.Document,
},
});
@@ -202,6 +278,25 @@ describe("#subscriptions.info", () => {
expect(response0.data.documentId).toEqual(document0.id);
});
it("should throw 400 when neither documentId nor collectionId is provided", async () => {
const user = await buildUser();
const res = await server.post("/api/subscriptions.info", {
body: {
token: user.getJwtToken(),
event: SubscriptionType.Document,
},
});
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(
"body: one of collectionId or documentId is required"
);
});
it("should throw 404 if no subscription found", async () => {
const author = await buildUser();
const subscriber = await buildUser({ teamId: author.teamId });
@@ -214,7 +309,7 @@ describe("#subscriptions.info", () => {
body: {
token: subscriber.getJwtToken(),
documentId: document.id,
event: "documents.update",
event: SubscriptionType.Document,
},
});
@@ -243,7 +338,7 @@ describe("#subscriptions.info", () => {
body: {
token: subscriber.getJwtToken(),
documentId: document0.id,
event: "documents.update",
event: SubscriptionType.Document,
},
});
@@ -252,17 +347,16 @@ describe("#subscriptions.info", () => {
body: {
token: subscriber.getJwtToken(),
documentId: document1.id,
event: "documents.update",
event: SubscriptionType.Document,
},
});
// `viewer` wants info about `subscriber`'s
// subscription on `document0`.
// `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",
event: SubscriptionType.Document,
},
});
@@ -274,13 +368,12 @@ describe("#subscriptions.info", () => {
expect(response0.error).toEqual("authorization_error");
expect(response0.message).toEqual("Authorization error");
// `viewer` wants info about `subscriber`'s
// subscription on `document0`.
// `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",
event: SubscriptionType.Document,
},
});
@@ -316,7 +409,7 @@ describe("#subscriptions.info", () => {
body: {
token: subscriber.getJwtToken(),
documentId: document0.id,
event: "documents.update",
event: SubscriptionType.Document,
},
});
@@ -325,13 +418,11 @@ describe("#subscriptions.info", () => {
body: {
token: subscriber.getJwtToken(),
documentId: document1.id,
event: "documents.update",
event: SubscriptionType.Document,
},
});
// `viewer` wants info about `subscriber`'s
// subscription on `document0`.
// They have requested an invalid event.
// `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(),
@@ -372,7 +463,7 @@ describe("#subscriptions.info", () => {
});
describe("#subscriptions.list", () => {
it("should list user subscriptions", async () => {
it("should list user subscriptions for the document", async () => {
const user = await buildUser();
const document = await buildDocument({
@@ -380,19 +471,16 @@ describe("#subscriptions.list", () => {
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",
event: SubscriptionType.Document,
},
});
@@ -594,6 +682,25 @@ describe("#subscriptions.list", () => {
expect(body.error).toEqual("authorization_error");
expect(body.message).toEqual("Authorization error");
});
it("should throw 400 when neither documentId nor collectionId is provided", async () => {
const user = await buildUser();
const res = await server.post("/api/subscriptions.list", {
body: {
token: user.getJwtToken(),
event: SubscriptionType.Document,
},
});
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(
"body: one of collectionId or documentId is required"
);
});
});
describe("#subscriptions.delete", () => {
@@ -608,7 +715,6 @@ describe("#subscriptions.delete", () => {
const subscription = await buildSubscription({
userId: user.id,
documentId: document.id,
event: "documents.update",
});
const res = await server.post("/api/subscriptions.delete", {
@@ -637,7 +743,6 @@ describe("#subscriptions.delete", () => {
const subscription = await buildSubscription({
userId: user.id,
documentId: document.id,
event: "documents.update",
});
const res = await server.post("/api/subscriptions.delete", {

View File

@@ -1,5 +1,5 @@
import Router from "koa-router";
import { Transaction } from "sequelize";
import { Transaction, WhereOptions } from "sequelize";
import { QueryNotices } from "@shared/types";
import subscriptionCreator from "@server/commands/subscriptionCreator";
import { createContext } from "@server/context";
@@ -8,7 +8,7 @@ import auth from "@server/middlewares/authentication";
import { rateLimiter } from "@server/middlewares/rateLimiter";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { Subscription, Document, User } from "@server/models";
import { Subscription, Document, User, Collection } from "@server/models";
import SubscriptionHelper from "@server/models/helpers/SubscriptionHelper";
import { authorize } from "@server/policies";
import { presentSubscription } from "@server/presenters";
@@ -26,18 +26,32 @@ router.post(
validate(T.SubscriptionsListSchema),
async (ctx: APIContext<T.SubscriptionsListReq>) => {
const { user } = ctx.state.auth;
const { documentId, event } = ctx.input.body;
const { event, collectionId, documentId } = ctx.input.body;
const document = await Document.findByPk(documentId, { userId: user.id });
const where: WhereOptions<Subscription> = {
userId: user.id,
event,
};
authorize(user, "read", document);
if (collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
authorize(user, "read", collection);
where.collectionId = collectionId;
} else {
// documentId will be available here
const document = await Document.findByPk(documentId!, {
userId: user.id,
});
authorize(user, "read", document);
where.documentId = documentId;
}
const subscriptions = await Subscription.findAll({
where: {
documentId: document.id,
userId: user.id,
event,
},
where,
order: [["createdAt", "DESC"]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
@@ -56,19 +70,33 @@ router.post(
validate(T.SubscriptionsInfoSchema),
async (ctx: APIContext<T.SubscriptionsInfoReq>) => {
const { user } = ctx.state.auth;
const { documentId, event } = ctx.input.body;
const { event, collectionId, documentId } = ctx.input.body;
const document = await Document.findByPk(documentId, { userId: user.id });
const where: WhereOptions<Subscription> = {
userId: user.id,
event,
};
authorize(user, "read", document);
if (collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
authorize(user, "read", collection);
where.collectionId = collectionId;
} else {
// documentId will be available here
const document = await Document.findByPk(documentId!, {
userId: user.id,
});
authorize(user, "read", document);
where.documentId = documentId;
}
// There can be only one subscription with these props.
const subscription = await Subscription.findOne({
where: {
userId: user.id,
documentId: document.id,
event,
},
where,
rejectOnEmpty: true,
});
@@ -84,20 +112,28 @@ router.post(
validate(T.SubscriptionsCreateSchema),
transaction(),
async (ctx: APIContext<T.SubscriptionsCreateReq>) => {
const { transaction } = ctx.state;
const { user } = ctx.state.auth;
const { documentId, event } = ctx.input.body;
const { event, collectionId, documentId } = ctx.input.body;
const document = await Document.findByPk(documentId, {
userId: user.id,
transaction,
});
if (collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
authorize(user, "subscribe", document);
authorize(user, "subscribe", collection);
} else {
// documentId will be available here
const document = await Document.findByPk(documentId!, {
userId: user.id,
});
authorize(user, "subscribe", document);
}
const subscription = await subscriptionCreator({
ctx,
documentId: document.id,
documentId,
collectionId,
event,
});

View File

@@ -15,6 +15,7 @@ import {
NotificationEventType,
ProsemirrorData,
ReactionSummary,
SubscriptionType,
UserRole,
} from "@shared/types";
import { parser, schema } from "@server/editor";
@@ -120,7 +121,7 @@ export async function buildSubscription(overrides: Partial<Subscription> = {}) {
overrides.userId = user.id;
}
if (!overrides.documentId) {
if (!overrides.documentId && !overrides.collectionId) {
const document = await buildDocument({
createdById: overrides.userId,
teamId: user.teamId,
@@ -129,7 +130,7 @@ export async function buildSubscription(overrides: Partial<Subscription> = {}) {
}
return Subscription.create({
event: "documents.update",
event: SubscriptionType.Document,
...overrides,
});
}

View File

@@ -427,6 +427,7 @@ export type SubscriptionEvent = BaseEvent<Subscription> & {
modelId: string;
userId: string;
documentId: string | null;
collectionId: string | null;
};
export type ViewEvent = BaseEvent<View> & {

View File

@@ -11,6 +11,10 @@
"Search in collection": "Search in collection",
"Star": "Star",
"Unstar": "Unstar",
"Subscribe": "Subscribe",
"Subscribed to document notifications": "Subscribed to document notifications",
"Unsubscribe": "Unsubscribe",
"Unsubscribed from document notifications": "Unsubscribed from document notifications",
"Archive": "Archive",
"Archive collection": "Archive collection",
"Collection archived": "Collection archived",
@@ -44,10 +48,6 @@
"Publish document": "Publish document",
"Unpublish": "Unpublish",
"Unpublished {{ documentName }}": "Unpublished {{ documentName }}",
"Subscribe": "Subscribe",
"Subscribed to document notifications": "Subscribed to document notifications",
"Unsubscribe": "Unsubscribe",
"Unsubscribed from document notifications": "Unsubscribed from document notifications",
"Share this document": "Share this document",
"HTML": "HTML",
"PDF": "PDF",
@@ -533,6 +533,7 @@
"{{ documentName }} restored": "{{ documentName }} restored",
"Document options": "Document options",
"Choose a collection": "Choose a collection",
"Subscription inherited from collection": "Subscription inherited from collection",
"Enable embeds": "Enable embeds",
"Export options": "Export options",
"Group members": "Group members",

View File

@@ -270,6 +270,10 @@ export type CollectionSort = {
direction: "asc" | "desc";
};
export enum SubscriptionType {
Document = "documents.update",
}
export enum NotificationEventType {
PublishDocument = "documents.publish",
UpdateDocument = "documents.update",