Remove NotificationSettings table (#5036

* helper

* Add script to move notification settings

* wip, removal of NotificationSettings

* event name

* iteration

* test

* test

* Remove last of NotificationSettings model

* refactor

* More fixes

* snapshots

* Change emails to class instances for type safety

* test

* docs

* Update migration for self-hosted

* tsc
This commit is contained in:
Tom Moor
2023-03-18 09:32:41 -04:00
committed by GitHub
parent 41f97b0563
commit 45831e9469
58 changed files with 972 additions and 711 deletions

View File

@@ -2,12 +2,13 @@ import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import { FileOperationFormat } from "@shared/types";
import { FileOperationFormat, NotificationEventType } from "@shared/types";
import Collection from "~/models/Collection";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import env from "~/env";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
@@ -20,15 +21,12 @@ function ExportDialog({ collection, onSubmit }: Props) {
const [format, setFormat] = React.useState<FileOperationFormat>(
FileOperationFormat.MarkdownZip
);
const user = useCurrentUser();
const { showToast } = useToasts();
const { collections, notificationSettings } = useStores();
const { collections } = useStores();
const { t } = useTranslation();
const appName = env.APP_NAME;
React.useEffect(() => {
notificationSettings.fetchPage({});
}, [notificationSettings]);
const handleFormatChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setFormat(ev.target.value as FileOperationFormat);
@@ -86,7 +84,7 @@ function ExportDialog({ collection, onSubmit }: Props) {
em: <strong />,
}}
/>{" "}
{notificationSettings.getByEvent("emails.export_completed") &&
{user.subscribedToEventType(NotificationEventType.ExportCompleted) &&
t("You will receive an email when it's complete.")}
</Text>
)}

View File

@@ -1,15 +0,0 @@
import { observable } from "mobx";
import BaseModel from "./BaseModel";
import Field from "./decorators/Field";
class NotificationSetting extends BaseModel {
@Field
@observable
id: string;
@Field
@observable
event: string;
}
export default NotificationSetting;

View File

@@ -1,7 +1,14 @@
import { subMinutes } from "date-fns";
import { computed, observable } from "mobx";
import { computed, action, observable } from "mobx";
import { now } from "mobx-utils";
import type { Role, UserPreference, UserPreferences } from "@shared/types";
import {
NotificationEventDefaults,
NotificationEventType,
UserPreference,
UserPreferences,
} from "@shared/types";
import type { Role, NotificationSettings } from "@shared/types";
import { client } from "~/utils/ApiClient";
import ParanoidModel from "./ParanoidModel";
import Field from "./decorators/Field";
@@ -30,6 +37,10 @@ class User extends ParanoidModel {
@observable
preferences: UserPreferences | null;
@Field
@observable
notificationSettings: NotificationSettings;
email: string;
isAdmin: boolean;
@@ -72,6 +83,49 @@ class User extends ParanoidModel {
}
}
/**
* Returns the current preference for the given notification event type taking
* into account the default system value.
*
* @param type The type of notification event
* @returns The current preference
*/
public subscribedToEventType = (type: NotificationEventType) => {
return (
this.notificationSettings[type] ??
NotificationEventDefaults[type] ??
false
);
};
/**
* Sets a preference for the users notification settings on the model and
* saves the change to the server.
*
* @param type The type of notification event
* @param value Set the preference to true/false
*/
@action
setNotificationEventType = async (
eventType: NotificationEventType,
value: boolean
) => {
this.notificationSettings = {
...this.notificationSettings,
[eventType]: value,
};
if (value) {
await client.post(`/users.notificationsSubscribe`, {
eventType,
});
} else {
await client.post(`/users.notificationsUnsubscribe`, {
eventType,
});
}
};
/**
* Get the value for a specific preference key, or return the fallback if
* none is set.

View File

@@ -3,6 +3,7 @@ import { observer } from "mobx-react";
import { EmailIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { NotificationEventType } from "@shared/types";
import Heading from "~/components/Heading";
import Input from "~/components/Input";
import Notice from "~/components/Notice";
@@ -11,48 +12,60 @@ import Switch from "~/components/Switch";
import Text from "~/components/Text";
import env from "~/env";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import isCloudHosted from "~/utils/isCloudHosted";
import SettingRow from "./components/SettingRow";
function Notifications() {
const { notificationSettings } = useStores();
const { showToast } = useToasts();
const user = useCurrentUser();
const { t } = useTranslation();
const options = [
{
event: "documents.publish",
event: NotificationEventType.PublishDocument,
title: t("Document published"),
description: t(
"Receive a notification whenever a new document is published"
),
},
{
event: "documents.update",
event: NotificationEventType.UpdateDocument,
title: t("Document updated"),
description: t(
"Receive a notification when a document you created is edited"
"Receive a notification when a document you are subscribed to is edited"
),
},
{
event: "collections.create",
event: NotificationEventType.CreateComment,
title: t("Comment posted"),
description: t(
"Receive a notification when a document you are subscribed to or a thread you participated in receives a comment"
),
},
{
event: NotificationEventType.Mentioned,
title: t("Mentioned"),
description: t(
"Receive a notification when someone mentions you in a document or comment"
),
},
{
event: NotificationEventType.CreateCollection,
title: t("Collection created"),
description: t(
"Receive a notification whenever a new collection is created"
),
},
{
event: "emails.invite_accepted",
event: NotificationEventType.InviteAccepted,
title: t("Invite accepted"),
description: t(
"Receive a notification when someone you invited creates an account"
),
},
{
event: "emails.export_completed",
event: NotificationEventType.ExportCompleted,
title: t("Export completed"),
description: t(
"Receive a notification when an export you requested has been completed"
@@ -60,22 +73,18 @@ function Notifications() {
},
{
visible: isCloudHosted,
event: "emails.onboarding",
event: NotificationEventType.Onboarding,
title: t("Getting started"),
description: t("Tips on getting started with features and functionality"),
},
{
visible: isCloudHosted,
event: "emails.features",
event: NotificationEventType.Features,
title: t("New features"),
description: t("Receive an email when new features of note are added"),
},
];
React.useEffect(() => {
notificationSettings.fetchPage({});
}, [notificationSettings]);
const showSuccessMessage = debounce(() => {
showToast(t("Notifications saved"), {
type: "success",
@@ -84,19 +93,13 @@ function Notifications() {
const handleChange = React.useCallback(
async (ev: React.ChangeEvent<HTMLInputElement>) => {
const setting = notificationSettings.getByEvent(ev.target.name);
if (ev.target.checked) {
await notificationSettings.save({
event: ev.target.name,
});
} else if (setting) {
await notificationSettings.delete(setting);
}
await user.setNotificationEventType(
ev.target.name as NotificationEventType,
ev.target.checked
);
showSuccessMessage();
},
[notificationSettings, showSuccessMessage]
[user, showSuccessMessage]
);
const showSuccessNotice = window.location.search === "?success";
@@ -130,7 +133,7 @@ function Notifications() {
<h2>{t("Notifications")}</h2>
{options.map((option) => {
const setting = notificationSettings.getByEvent(option.event);
const setting = user.subscribedToEventType(option.event);
return (
<SettingRow
@@ -145,10 +148,6 @@ function Notifications() {
name={option.event}
checked={!!setting}
onChange={handleChange}
disabled={
(setting && setting.isSaving) ||
notificationSettings.isFetching
}
/>
</SettingRow>
);

View File

@@ -1,20 +0,0 @@
import { find } from "lodash";
import NotificationSetting from "~/models/NotificationSetting";
import BaseStore, { RPCAction } from "./BaseStore";
import RootStore from "./RootStore";
export default class NotificationSettingsStore extends BaseStore<
NotificationSetting
> {
actions = [RPCAction.List, RPCAction.Create, RPCAction.Delete];
constructor(rootStore: RootStore) {
super(rootStore, NotificationSetting);
}
getByEvent = (event: string) => {
return find(this.orderedData, {
event,
});
};
}

View File

@@ -13,7 +13,6 @@ import GroupMembershipsStore from "./GroupMembershipsStore";
import GroupsStore from "./GroupsStore";
import IntegrationsStore from "./IntegrationsStore";
import MembershipsStore from "./MembershipsStore";
import NotificationSettingsStore from "./NotificationSettingsStore";
import PinsStore from "./PinsStore";
import PoliciesStore from "./PoliciesStore";
import RevisionsStore from "./RevisionsStore";
@@ -41,7 +40,6 @@ export default class RootStore {
groupMemberships: GroupMembershipsStore;
integrations: IntegrationsStore;
memberships: MembershipsStore;
notificationSettings: NotificationSettingsStore;
presence: DocumentPresenceStore;
pins: PinsStore;
policies: PoliciesStore;
@@ -74,7 +72,6 @@ export default class RootStore {
this.integrations = new IntegrationsStore(this);
this.memberships = new MembershipsStore(this);
this.pins = new PinsStore(this);
this.notificationSettings = new NotificationSettingsStore(this);
this.presence = new DocumentPresenceStore();
this.revisions = new RevisionsStore(this);
this.searches = new SearchesStore(this);
@@ -102,7 +99,6 @@ export default class RootStore {
this.groupMemberships.clear();
this.integrations.clear();
this.memberships.clear();
this.notificationSettings.clear();
this.presence.clear();
this.pins.clear();
this.policies.clear();

View File

@@ -20,7 +20,7 @@ describe("email", () => {
});
it("should respond with redirect location when user is SSO enabled", async () => {
const spy = jest.spyOn(WelcomeEmail, "schedule");
const spy = jest.spyOn(WelcomeEmail.prototype, "schedule");
const user = await buildUser();
const res = await server.post("/auth/email", {
body: {
@@ -35,7 +35,7 @@ describe("email", () => {
});
it("should respond with success and email to be sent when user has SSO but disabled", async () => {
const spy = jest.spyOn(SigninEmail, "schedule");
const spy = jest.spyOn(SigninEmail.prototype, "schedule");
const team = await buildTeam({
subdomain: "example",
});
@@ -76,7 +76,7 @@ describe("email", () => {
env.DEPLOYMENT = "hosted";
const user = await buildUser();
const spy = jest.spyOn(WelcomeEmail, "schedule");
const spy = jest.spyOn(WelcomeEmail.prototype, "schedule");
await buildTeam({
subdomain: "example",
});
@@ -97,7 +97,7 @@ describe("email", () => {
});
it("should respond with success and email to be sent when user is not SSO enabled", async () => {
const spy = jest.spyOn(SigninEmail, "schedule");
const spy = jest.spyOn(SigninEmail.prototype, "schedule");
const team = await buildTeam({
subdomain: "example",
});
@@ -120,7 +120,7 @@ describe("email", () => {
});
it("should respond with success regardless of whether successful to prevent crawling email logins", async () => {
const spy = jest.spyOn(WelcomeEmail, "schedule");
const spy = jest.spyOn(WelcomeEmail.prototype, "schedule");
await buildTeam({
subdomain: "example",
});
@@ -140,7 +140,7 @@ describe("email", () => {
});
describe("with multiple users matching email", () => {
it("should default to current subdomain with SSO", async () => {
const spy = jest.spyOn(SigninEmail, "schedule");
const spy = jest.spyOn(SigninEmail.prototype, "schedule");
env.URL = sharedEnv.URL = "http://localoutline.com";
env.SUBDOMAINS_ENABLED = sharedEnv.SUBDOMAINS_ENABLED = true;
const email = "sso-user@example.org";
@@ -170,7 +170,7 @@ describe("email", () => {
});
it("should default to current subdomain with guest email", async () => {
const spy = jest.spyOn(SigninEmail, "schedule");
const spy = jest.spyOn(SigninEmail.prototype, "schedule");
env.URL = sharedEnv.URL = "http://localoutline.com";
env.SUBDOMAINS_ENABLED = sharedEnv.SUBDOMAINS_ENABLED = true;
const email = "guest-user@example.org";
@@ -200,7 +200,7 @@ describe("email", () => {
});
it("should default to custom domain with SSO", async () => {
const spy = jest.spyOn(WelcomeEmail, "schedule");
const spy = jest.spyOn(WelcomeEmail.prototype, "schedule");
const email = "sso-user-2@example.org";
const team = await buildTeam({
domain: "docs.mycompany.com",
@@ -228,7 +228,7 @@ describe("email", () => {
});
it("should default to custom domain with guest email", async () => {
const spy = jest.spyOn(SigninEmail, "schedule");
const spy = jest.spyOn(SigninEmail.prototype, "schedule");
const email = "guest-user-2@example.org";
const team = await buildTeam({
domain: "docs.mycompany.com",

View File

@@ -1,5 +1,5 @@
import Router from "koa-router";
import { Client } from "@shared/types";
import { Client, NotificationEventType } from "@shared/types";
import { parseDomain } from "@shared/utils/domains";
import InviteAcceptedEmail from "@server/emails/templates/InviteAcceptedEmail";
import SigninEmail from "@server/emails/templates/SigninEmail";
@@ -67,12 +67,13 @@ router.post(
}
// send email to users email address with a short-lived token
await SigninEmail.schedule({
await new SigninEmail({
to: user.email,
token: user.getEmailSigninToken(),
teamUrl: team.url,
client: client === Client.Desktop ? Client.Desktop : Client.Web,
});
}).schedule();
user.lastSigninEmailSentAt = new Date();
await user.save();
@@ -105,19 +106,19 @@ router.get("email.callback", async (ctx) => {
}
if (user.isInvited) {
await WelcomeEmail.schedule({
await new WelcomeEmail({
to: user.email,
teamUrl: user.team.url,
});
}).schedule();
const inviter = await user.$get("invitedBy");
if (inviter) {
await InviteAcceptedEmail.schedule({
if (inviter?.subscribedToEventType(NotificationEventType.InviteAccepted)) {
await new InviteAcceptedEmail({
to: inviter.email,
inviterId: inviter.id,
invitedName: user.name,
teamUrl: user.team.url,
});
}).schedule();
}
}

View File

@@ -647,11 +647,11 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
]);
if (createdBy && team) {
await WebhookDisabledEmail.schedule({
await new WebhookDisabledEmail({
to: createdBy.email,
teamUrl: team.url,
webhookName: subscription.name,
});
}).schedule();
}
}
}

View File

@@ -18,7 +18,7 @@ describe("accountProvisioner", () => {
});
it("should create a new user and team", async () => {
const spy = jest.spyOn(WelcomeEmail, "schedule");
const spy = jest.spyOn(WelcomeEmail.prototype, "schedule");
const { user, team, isNewTeam, isNewUser } = await accountProvisioner({
ip,
user: {
@@ -58,7 +58,7 @@ describe("accountProvisioner", () => {
});
it("should update exising user and authentication", async () => {
const spy = jest.spyOn(WelcomeEmail, "schedule");
const spy = jest.spyOn(WelcomeEmail.prototype, "schedule");
const existingTeam = await buildTeam();
const providers = await existingTeam.$get("authenticationProviders");
const authenticationProvider = providers[0];
@@ -230,7 +230,7 @@ describe("accountProvisioner", () => {
});
it("should create a new user in an existing team when the domain is allowed", async () => {
const spy = jest.spyOn(WelcomeEmail, "schedule");
const spy = jest.spyOn(WelcomeEmail.prototype, "schedule");
const { admin, team } = await seed();
const authenticationProviders = await team.$get(
"authenticationProviders"
@@ -280,7 +280,7 @@ describe("accountProvisioner", () => {
});
it("should create a new user in an existing team", async () => {
const spy = jest.spyOn(WelcomeEmail, "schedule");
const spy = jest.spyOn(WelcomeEmail.prototype, "schedule");
const team = await buildTeam();
const authenticationProviders = await team.$get(
"authenticationProviders"

View File

@@ -144,10 +144,10 @@ async function accountProvisioner({
const { isNewUser, user } = result;
if (isNewUser) {
await WelcomeEmail.schedule({
await new WelcomeEmail({
to: user.email,
teamUrl: team.url,
});
}).schedule();
}
if (isNewUser || isNewTeam) {

View File

@@ -12,7 +12,6 @@ import {
FileOperation,
Group,
Team,
NotificationSetting,
User,
UserAuthentication,
Integration,
@@ -154,13 +153,6 @@ async function teamPermanentDeleter(team: Team) {
force: true,
transaction,
});
await NotificationSetting.destroy({
where: {
teamId,
},
force: true,
transaction,
});
await SearchQuery.destroy({
where: {
teamId,

View File

@@ -80,14 +80,14 @@ export default async function userInviter({
ip,
});
await InviteEmail.schedule({
await new InviteEmail({
to: invite.email,
name: invite.name,
actorName: user.name,
actorEmail: user.email,
teamName: team.name,
teamUrl: team.url,
});
}).schedule();
if (env.ENVIRONMENT === "development") {
Logger.info(

View File

@@ -181,12 +181,12 @@ export default async function userProvisioner({
if (isInvite) {
const inviter = await existingUser.$get("invitedBy");
if (inviter) {
await InviteAcceptedEmail.schedule({
await new InviteAcceptedEmail({
to: inviter.email,
inviterId: inviter.id,
invitedName: existingUser.name,
teamUrl: existingUser.team.url,
});
}).schedule();
}
}

View File

@@ -1,3 +1,4 @@
import Bull from "bull";
import mailer from "@server/emails/mailer";
import Logger from "@server/logging/Logger";
import Metrics from "@server/logging/Metrics";
@@ -6,8 +7,8 @@ import { taskQueue } from "@server/queues";
import { TaskPriority } from "@server/queues/tasks/BaseTask";
import { NotificationMetadata } from "@server/types";
interface EmailProps {
to: string;
export interface EmailProps {
to: string | null;
}
export default abstract class BaseEmail<T extends EmailProps, S = unknown> {
@@ -17,12 +18,11 @@ export default abstract class BaseEmail<T extends EmailProps, S = unknown> {
/**
* Schedule this email type to be sent asyncronously by a worker.
*
* @param props Properties to be used in the email template
* @param metadata Optional metadata to be stored with the notification
* @param options Options to pass to the Bull queue
* @returns A promise that resolves once the email is placed on the task queue
*/
public static schedule<T>(props: T, metadata?: NotificationMetadata) {
const templateName = this.name;
public schedule(options?: Bull.JobOptions) {
const templateName = this.constructor.name;
Metrics.increment("email.scheduled", {
templateName,
@@ -35,8 +35,8 @@ export default abstract class BaseEmail<T extends EmailProps, S = unknown> {
name: "EmailTask",
props: {
templateName,
...metadata,
props,
...this.metadata,
props: this.props,
},
},
{
@@ -46,6 +46,7 @@ export default abstract class BaseEmail<T extends EmailProps, S = unknown> {
type: "exponential",
delay: 60 * 1000,
},
...options,
}
);
}
@@ -73,6 +74,15 @@ export default abstract class BaseEmail<T extends EmailProps, S = unknown> {
return;
}
if (!this.props.to) {
Logger.info(
"email",
`Email ${templateName} not sent due to missing email address`,
this.props
);
return;
}
const data = { ...this.props, ...(bsResponse ?? ({} as S)) };
try {

View File

@@ -1,6 +1,8 @@
import * as React from "react";
import { NotificationEventType } from "@shared/types";
import env from "@server/env";
import { Collection } from "@server/models";
import { Collection, User } from "@server/models";
import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper";
import BaseEmail from "./BaseEmail";
import Body from "./components/Body";
import Button from "./components/Button";
@@ -12,13 +14,13 @@ import Heading from "./components/Heading";
type InputProps = {
to: string;
eventName: string;
userId: string;
collectionId: string;
unsubscribeUrl: string;
};
type BeforeSend = {
collection: Collection;
unsubscribeUrl: string;
};
type Props = InputProps & BeforeSend;
@@ -27,12 +29,11 @@ type Props = InputProps & BeforeSend;
* Email sent to a user when they have enabled notifications of new collection
* creation.
*/
export default class CollectionNotificationEmail extends BaseEmail<
export default class CollectionCreatedEmail extends BaseEmail<
InputProps,
BeforeSend
> {
protected async beforeSend({ collectionId }: Props) {
protected async beforeSend({ userId, collectionId }: Props) {
const collection = await Collection.scope("withUser").findByPk(
collectionId
);
@@ -40,32 +41,39 @@ export default class CollectionNotificationEmail extends BaseEmail<
return false;
}
return { collection };
const user = await User.findByPk(userId);
if (!user) {
return false;
}
return {
collection,
unsubscribeUrl: NotificationSettingsHelper.unsubscribeUrl(
user,
NotificationEventType.CreateCollection
),
};
}
protected subject({ collection, eventName }: Props) {
return `${collection.name}${eventName}`;
protected subject({ collection }: Props) {
return `${collection.name}created`;
}
protected preview({ collection, eventName }: Props) {
return `${collection.user.name} ${eventName} a collection`;
protected preview({ collection }: Props) {
return `${collection.user.name} created a collection`;
}
protected renderAsText({ collection, eventName = "created" }: Props) {
protected renderAsText({ collection }: Props) {
return `
${collection.name}
${collection.user.name} ${eventName} the collection "${collection.name}"
${collection.user.name} created the collection "${collection.name}"
Open Collection: ${env.URL}${collection.url}
`;
}
protected render({
collection,
eventName = "created",
unsubscribeUrl,
}: Props) {
protected render({ collection, unsubscribeUrl }: Props) {
return (
<EmailTemplate>
<Header />
@@ -73,7 +81,7 @@ Open Collection: ${env.URL}${collection.url}
<Body>
<Heading>{collection.name}</Heading>
<p>
{collection.user.name} {eventName} the collection "{collection.name}
{collection.user.name} created the collection "{collection.name}
".
</p>
<EmptySpace height={10} />

View File

@@ -1,8 +1,10 @@
import inlineCss from "inline-css";
import * as React from "react";
import { NotificationEventType } from "@shared/types";
import env from "@server/env";
import { Comment, Document } from "@server/models";
import BaseEmail from "./BaseEmail";
import { Comment, Document, User } from "@server/models";
import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper";
import BaseEmail, { EmailProps } from "./BaseEmail";
import Body from "./components/Body";
import Button from "./components/Button";
import Diff from "./components/Diff";
@@ -12,15 +14,14 @@ import Footer from "./components/Footer";
import Header from "./components/Header";
import Heading from "./components/Heading";
type InputProps = {
to: string;
type InputProps = EmailProps & {
userId: string;
documentId: string;
actorName: string;
isReply: boolean;
commentId: string;
collectionName: string;
collectionName: string | undefined;
teamUrl: string;
unsubscribeUrl: string;
content: string;
};
@@ -28,24 +29,35 @@ type BeforeSend = {
document: Document;
body: string | undefined;
isFirstComment: boolean;
unsubscribeUrl: string;
};
type Props = InputProps & BeforeSend;
/**
* Email sent to a user when they are subscribed to a document and a new comment
* is created.
* Email sent to a user when a new comment is created in a document they are
* subscribed to.
*/
export default class CommentCreatedEmail extends BaseEmail<
InputProps,
BeforeSend
> {
protected async beforeSend({ documentId, commentId, content }: InputProps) {
protected async beforeSend({
documentId,
userId,
commentId,
content,
}: InputProps) {
const document = await Document.unscoped().findByPk(documentId);
if (!document) {
return false;
}
const user = await User.findByPk(userId);
if (!user) {
return false;
}
const firstComment = await Comment.findOne({
attributes: ["id"],
where: { documentId },
@@ -64,7 +76,15 @@ export default class CommentCreatedEmail extends BaseEmail<
});
}
return { document, isFirstComment, body };
return {
document,
isFirstComment,
body,
unsubscribeUrl: NotificationSettingsHelper.unsubscribeUrl(
user,
NotificationEventType.CreateComment
),
};
}
protected subject({ isFirstComment, document }: Props) {
@@ -92,7 +112,7 @@ export default class CommentCreatedEmail extends BaseEmail<
return `
${actorName} ${isReply ? "replied to a thread in" : "commented on"} "${
document.title
}", in the ${collectionName} collection.
}"${collectionName ? `in the ${collectionName} collection` : ""}.
Open Thread: ${teamUrl}${document.url}?commentId=${commentId}
`;
@@ -118,8 +138,8 @@ Open Thread: ${teamUrl}${document.url}?commentId=${commentId}
<Heading>{document.title}</Heading>
<p>
{actorName} {isReply ? "replied to a thread in" : "commented on"}{" "}
<a href={link}>{document.title}</a>, in the {collectionName}{" "}
collection.
<a href={link}>{document.title}</a>{" "}
{collectionName ? `in the ${collectionName} collection` : ""}.
</p>
{body && (
<>

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import env from "@server/env";
import BaseEmail from "./BaseEmail";
import BaseEmail, { EmailProps } from "./BaseEmail";
import Body from "./components/Body";
import CopyableCode from "./components/CopyableCode";
import EmailTemplate from "./components/EmailLayout";
@@ -9,8 +9,7 @@ import Footer from "./components/Footer";
import Header from "./components/Header";
import Heading from "./components/Heading";
type Props = {
to: string;
type Props = EmailProps & {
deleteConfirmationCode: string;
};

View File

@@ -1,8 +1,10 @@
import inlineCss from "inline-css";
import * as React from "react";
import { NotificationEventType } from "@shared/types";
import env from "@server/env";
import { Document } from "@server/models";
import BaseEmail from "./BaseEmail";
import { Document, User } from "@server/models";
import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper";
import BaseEmail, { EmailProps } from "./BaseEmail";
import Body from "./components/Body";
import Button from "./components/Button";
import Diff from "./components/Diff";
@@ -12,19 +14,21 @@ import Footer from "./components/Footer";
import Header from "./components/Header";
import Heading from "./components/Heading";
type InputProps = {
to: string;
type InputProps = EmailProps & {
userId: string;
documentId: string;
actorName: string;
collectionName: string;
eventName: string;
eventType:
| NotificationEventType.PublishDocument
| NotificationEventType.UpdateDocument;
teamUrl: string;
unsubscribeUrl: string;
content: string;
content?: string;
};
type BeforeSend = {
document: Document;
unsubscribeUrl: string;
body: string | undefined;
};
@@ -38,12 +42,22 @@ export default class DocumentNotificationEmail extends BaseEmail<
InputProps,
BeforeSend
> {
protected async beforeSend({ documentId, content }: InputProps) {
protected async beforeSend({
documentId,
eventType,
userId,
content,
}: InputProps) {
const document = await Document.unscoped().findByPk(documentId);
if (!document) {
return false;
}
const user = await User.findByPk(userId);
if (!user) {
return false;
}
// inline all css so that it works in as many email providers as possible.
let body;
if (content) {
@@ -55,15 +69,33 @@ export default class DocumentNotificationEmail extends BaseEmail<
});
}
return { document, body };
return {
document,
body,
unsubscribeUrl: NotificationSettingsHelper.unsubscribeUrl(
user,
eventType
),
};
}
protected subject({ document, eventName }: Props) {
return `${document.title}${eventName}`;
eventName(eventType: NotificationEventType) {
switch (eventType) {
case NotificationEventType.PublishDocument:
return "published";
case NotificationEventType.UpdateDocument:
return "updated";
default:
return "";
}
}
protected preview({ actorName, eventName }: Props): string {
return `${actorName} ${eventName} a document`;
protected subject({ document, eventType }: Props) {
return `${document.title} ${this.eventName(eventType)}`;
}
protected preview({ actorName, eventType }: Props): string {
return `${actorName} ${this.eventName(eventType)} a document`;
}
protected renderAsText({
@@ -71,8 +103,10 @@ export default class DocumentNotificationEmail extends BaseEmail<
teamUrl,
document,
collectionName,
eventName = "published",
eventType,
}: Props): string {
const eventName = this.eventName(eventType);
return `
"${document.title}" ${eventName}
@@ -86,12 +120,13 @@ Open Document: ${teamUrl}${document.url}
document,
actorName,
collectionName,
eventName = "published",
eventType,
teamUrl,
unsubscribeUrl,
body,
}: Props) {
const link = `${teamUrl}${document.url}?ref=notification-email`;
const eventName = this.eventName(eventType);
return (
<EmailTemplate>

View File

@@ -1,6 +1,8 @@
import * as React from "react";
import { NotificationSetting } from "@server/models";
import BaseEmail from "./BaseEmail";
import { NotificationEventType } from "@shared/types";
import { User } from "@server/models";
import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper";
import BaseEmail, { EmailProps } from "./BaseEmail";
import Body from "./components/Body";
import Button from "./components/Button";
import EmailTemplate from "./components/EmailLayout";
@@ -9,27 +11,32 @@ import Footer from "./components/Footer";
import Header from "./components/Header";
import Heading from "./components/Heading";
type Props = {
to: string;
type Props = EmailProps & {
userId: string;
teamUrl: string;
teamId: string;
};
type BeforeSendProps = {
unsubscribeUrl: string;
};
/**
* Email sent to a user when their data export has failed for some reason.
*/
export default class ExportFailureEmail extends BaseEmail<Props> {
protected async beforeSend({ userId, teamId }: Props) {
const notificationSetting = await NotificationSetting.findOne({
where: {
userId,
teamId,
event: "emails.export_completed",
},
});
protected async beforeSend({ userId }: Props) {
const user = await User.findByPk(userId);
if (!user) {
return false;
}
return notificationSetting !== null;
return {
unsubscribeUrl: NotificationSettingsHelper.unsubscribeUrl(
user,
NotificationEventType.ExportCompleted
),
};
}
protected subject() {
@@ -49,7 +56,7 @@ section to try again if the problem persists please contact support.
`;
}
protected render({ teamUrl }: Props) {
protected render({ teamUrl, unsubscribeUrl }: Props & BeforeSendProps) {
return (
<EmailTemplate>
<Header />
@@ -71,7 +78,7 @@ section to try again if the problem persists please contact support.
<Button href={`${teamUrl}/settings/export`}>Go to export</Button>
</p>
</Body>
<Footer />
<Footer unsubscribeUrl={unsubscribeUrl} />
</EmailTemplate>
);
}

View File

@@ -1,7 +1,9 @@
import * as React from "react";
import { NotificationEventType } from "@shared/types";
import env from "@server/env";
import { NotificationSetting } from "@server/models";
import BaseEmail from "./BaseEmail";
import { User } from "@server/models";
import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper";
import BaseEmail, { EmailProps } from "./BaseEmail";
import Body from "./components/Body";
import Button from "./components/Button";
import EmailTemplate from "./components/EmailLayout";
@@ -10,29 +12,34 @@ import Footer from "./components/Footer";
import Header from "./components/Header";
import Heading from "./components/Heading";
type Props = {
to: string;
type Props = EmailProps & {
userId: string;
id: string;
teamUrl: string;
teamId: string;
};
type BeforeSendProps = {
unsubscribeUrl: string;
};
/**
* Email sent to a user when their data export has completed and is available
* for download in the settings section.
*/
export default class ExportSuccessEmail extends BaseEmail<Props> {
protected async beforeSend({ userId, teamId }: Props) {
const notificationSetting = await NotificationSetting.findOne({
where: {
userId,
teamId,
event: "emails.export_completed",
},
});
protected async beforeSend({ userId }: Props) {
const user = await User.findByPk(userId);
if (!user) {
return false;
}
return notificationSetting !== null;
return {
unsubscribeUrl: NotificationSettingsHelper.unsubscribeUrl(
user,
NotificationEventType.ExportCompleted
),
};
}
protected subject() {
@@ -51,7 +58,7 @@ Your requested data export is complete, the exported files are also available in
`;
}
protected render({ id, teamUrl }: Props) {
protected render({ id, teamUrl, unsubscribeUrl }: Props & BeforeSendProps) {
return (
<EmailTemplate>
<Header />
@@ -78,7 +85,7 @@ Your requested data export is complete, the exported files are also available in
</p>
</Body>
<Footer />
<Footer unsubscribeUrl={unsubscribeUrl} />
</EmailTemplate>
);
}

View File

@@ -1,7 +1,9 @@
import * as React from "react";
import { NotificationEventType } from "@shared/types";
import env from "@server/env";
import { NotificationSetting } from "@server/models";
import BaseEmail from "./BaseEmail";
import { User } from "@server/models";
import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper";
import BaseEmail, { EmailProps } from "./BaseEmail";
import Body from "./components/Body";
import Button from "./components/Button";
import EmailTemplate from "./components/EmailLayout";
@@ -10,8 +12,7 @@ import Footer from "./components/Footer";
import Header from "./components/Header";
import Heading from "./components/Heading";
type Props = {
to: string;
type Props = EmailProps & {
inviterId: string;
invitedName: string;
teamUrl: string;
@@ -26,17 +27,17 @@ type BeforeSendProps = {
*/
export default class InviteAcceptedEmail extends BaseEmail<Props> {
protected async beforeSend({ inviterId }: Props) {
const notificationSetting = await NotificationSetting.findOne({
where: {
userId: inviterId,
event: "emails.invite_accepted",
},
});
if (!notificationSetting) {
const inviter = await User.findByPk(inviterId);
if (!inviter) {
return false;
}
return { unsubscribeUrl: notificationSetting.unsubscribeUrl };
return {
unsubscribeUrl: NotificationSettingsHelper.unsubscribeUrl(
inviter,
NotificationEventType.InviteAccepted
),
};
}
protected subject({ invitedName }: Props) {

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import env from "@server/env";
import BaseEmail from "./BaseEmail";
import BaseEmail, { EmailProps } from "./BaseEmail";
import Body from "./components/Body";
import Button from "./components/Button";
import EmailTemplate from "./components/EmailLayout";
@@ -9,11 +9,10 @@ import Footer from "./components/Footer";
import Header from "./components/Header";
import Heading from "./components/Heading";
type Props = {
to: string;
type Props = EmailProps & {
name: string;
actorName: string;
actorEmail: string;
actorEmail: string | null;
teamName: string;
teamUrl: string;
};
@@ -39,7 +38,9 @@ export default class InviteEmail extends BaseEmail<Props> {
return `
Join ${teamName} on ${env.APP_NAME}
${actorName} (${actorEmail}) has invited you to join ${env.APP_NAME}, a place for your team to build and share knowledge.
${actorName} ${actorEmail ? `(${actorEmail})` : ""} has invited you to join ${
env.APP_NAME
}, a place for your team to build and share knowledge.
Join now: ${teamUrl}
`;
@@ -55,8 +56,9 @@ Join now: ${teamUrl}
Join {teamName} on {env.APP_NAME}
</Heading>
<p>
{actorName} ({actorEmail}) has invited you to join {env.APP_NAME}, a
place for your team to build and share knowledge.
{actorName} {actorEmail ? `(${actorEmail})` : ""} has invited you to
join {env.APP_NAME}, a place for your team to build and share
knowledge.
</p>
<EmptySpace height={10} />
<p>

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import env from "@server/env";
import BaseEmail from "./BaseEmail";
import BaseEmail, { EmailProps } from "./BaseEmail";
import Body from "./components/Body";
import Button from "./components/Button";
import EmailTemplate from "./components/EmailLayout";
@@ -9,11 +9,10 @@ import Footer from "./components/Footer";
import Header from "./components/Header";
import Heading from "./components/Heading";
type Props = {
to: string;
type Props = EmailProps & {
name: string;
actorName: string;
actorEmail: string;
actorEmail: string | null;
teamName: string;
teamUrl: string;
};
@@ -38,7 +37,11 @@ export default class InviteReminderEmail extends BaseEmail<Props> {
teamUrl,
}: Props): string {
return `
This is just a quick reminder that ${actorName} (${actorEmail}) invited you to join them in the ${teamName} team on ${env.APP_NAME}, a place for your team to build and share knowledge.
This is just a quick reminder that ${actorName} ${
actorEmail ? `(${actorEmail})` : ""
} invited you to join them in the ${teamName} team on ${
env.APP_NAME
}, a place for your team to build and share knowledge.
We only send a reminder once.
If you haven't signed up yet, you can do so here: ${teamUrl}
@@ -55,7 +58,8 @@ If you haven't signed up yet, you can do so here: ${teamUrl}
Join {teamName} on {env.APP_NAME}
</Heading>
<p>
This is just a quick reminder that {actorName} ({actorEmail})
This is just a quick reminder that {actorName}{" "}
{actorEmail ? `(${actorEmail})` : ""}
invited you to join them in the {teamName} team on {env.APP_NAME}, a
place for your team to build and share knowledge.
</p>

View File

@@ -1,14 +1,13 @@
import * as React from "react";
import { Document } from "@server/models";
import BaseEmail from "./BaseEmail";
import BaseEmail, { EmailProps } from "./BaseEmail";
import Body from "./components/Body";
import Button from "./components/Button";
import EmailTemplate from "./components/EmailLayout";
import Header from "./components/Header";
import Heading from "./components/Heading";
type InputProps = {
to: string;
type InputProps = EmailProps & {
documentId: string;
actorName: string;
teamUrl: string;
@@ -22,7 +21,7 @@ type BeforeSend = {
type Props = InputProps & BeforeSend;
/**
* Email sent to a user when someone mentions them in a doucment
* Email sent to a user when someone mentions them in a document.
*/
export default class MentionNotificationEmail extends BaseEmail<
InputProps,

View File

@@ -2,7 +2,7 @@ import * as React from "react";
import { Client } from "@shared/types";
import env from "@server/env";
import logger from "@server/logging/Logger";
import BaseEmail from "./BaseEmail";
import BaseEmail, { EmailProps } from "./BaseEmail";
import Body from "./components/Body";
import Button from "./components/Button";
import EmailTemplate from "./components/EmailLayout";
@@ -11,8 +11,7 @@ import Footer from "./components/Footer";
import Header from "./components/Header";
import Heading from "./components/Heading";
type Props = {
to: string;
type Props = EmailProps & {
token: string;
teamUrl: string;
client: Client;

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import BaseEmail from "./BaseEmail";
import BaseEmail, { EmailProps } from "./BaseEmail";
import Body from "./components/Body";
import Button from "./components/Button";
import EmailTemplate from "./components/EmailLayout";
@@ -8,8 +8,7 @@ import Footer from "./components/Footer";
import Header from "./components/Header";
import Heading from "./components/Heading";
type Props = {
to: string;
type Props = EmailProps & {
teamUrl: string;
webhookName: string;
};

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import env from "@server/env";
import BaseEmail from "./BaseEmail";
import BaseEmail, { EmailProps } from "./BaseEmail";
import Body from "./components/Body";
import Button from "./components/Button";
import EmailTemplate from "./components/EmailLayout";
@@ -9,8 +9,7 @@ import Footer from "./components/Footer";
import Header from "./components/Header";
import Heading from "./components/Heading";
type Props = {
to: string;
type Props = EmailProps & {
teamUrl: string;
};

View File

@@ -0,0 +1,66 @@
"use strict";
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async (transaction) => {
await queryInterface.addColumn("users", "notificationSettings", {
type: Sequelize.JSONB,
allowNull: false,
defaultValue: {},
transaction,
});
if (process.env.DEPLOYMENT === "hosted") {
return;
}
// In cloud hosted Outline this migration is done in a script instead due
// to scale considerations.
const users = await queryInterface.sequelize.query(
"SELECT id FROM users",
{
type: queryInterface.sequelize.QueryTypes.SELECT,
transaction
}
);
for (const user of users) {
const settings = await queryInterface.sequelize.query(
`SELECT * FROM notification_settings WHERE "userId" = :userId`,
{
type: queryInterface.sequelize.QueryTypes.SELECT,
replacements: { userId: user.id },
transaction
}
);
const eventTypes = settings.map((setting) => setting.event);
if (eventTypes.length > 0) {
const notificationSettings = {};
for (const eventType of eventTypes) {
notificationSettings[eventType] = true;
}
await queryInterface.sequelize.query(
`UPDATE users SET "notificationSettings" = :notificationSettings WHERE id = :userId`,
{
type: queryInterface.sequelize.QueryTypes.UPDATE,
replacements: {
userId: user.id,
notificationSettings: JSON.stringify(notificationSettings),
},
transaction
}
);
}
}
});
},
async down (queryInterface) {
return queryInterface.removeColumn("users", "notificationSettings");
}
};

View File

@@ -1,90 +0,0 @@
import crypto from "crypto";
import {
Table,
ForeignKey,
Model,
Column,
PrimaryKey,
IsUUID,
CreatedAt,
BelongsTo,
IsIn,
Default,
DataType,
Scopes,
} from "sequelize-typescript";
import env from "@server/env";
import Team from "./Team";
import User from "./User";
import Fix from "./decorators/Fix";
@Scopes(() => ({
withUser: {
include: [
{
association: "user",
required: true,
},
],
},
}))
@Table({
tableName: "notification_settings",
modelName: "notification_setting",
updatedAt: false,
})
@Fix
class NotificationSetting extends Model {
@IsUUID(4)
@PrimaryKey
@Default(DataType.UUIDV4)
@Column
id: string;
@CreatedAt
createdAt: Date;
@IsIn([
[
"documents.publish",
"documents.update",
"collections.create",
"emails.invite_accepted",
"emails.onboarding",
"emails.features",
"emails.export_completed",
],
])
@Column(DataType.STRING)
event: string;
// getters
get unsubscribeUrl() {
return `${env.URL}/api/notificationSettings.unsubscribe?token=${this.unsubscribeToken}&id=${this.id}`;
}
get unsubscribeToken() {
const hash = crypto.createHash("sha256");
hash.update(`${this.userId}-${env.SECRET_KEY}`);
return hash.digest("hex");
}
// associations
@BelongsTo(() => User, "userId")
user: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
userId: string;
@BelongsTo(() => Team, "teamId")
team: Team;
@ForeignKey(() => Team)
@Column(DataType.UUID)
teamId: string;
}
export default NotificationSetting;

View File

@@ -12,7 +12,6 @@ import {
IsIn,
BeforeDestroy,
BeforeCreate,
AfterCreate,
BelongsTo,
ForeignKey,
DataType,
@@ -24,10 +23,13 @@ import {
AfterUpdate,
} from "sequelize-typescript";
import { languages } from "@shared/i18n";
import type { NotificationSettings } from "@shared/types";
import {
CollectionPermission,
UserPreference,
UserPreferences,
NotificationEventType,
NotificationEventDefaults,
} from "@shared/types";
import { stringToColor } from "@shared/utils/color";
import env from "@server/env";
@@ -39,7 +41,6 @@ import Attachment from "./Attachment";
import AuthenticationProvider from "./AuthenticationProvider";
import Collection from "./Collection";
import CollectionUser from "./CollectionUser";
import NotificationSetting from "./NotificationSetting";
import Star from "./Star";
import Team from "./Team";
import UserAuthentication from "./UserAuthentication";
@@ -175,6 +176,9 @@ class User extends ParanoidModel {
@Column(DataType.JSONB)
preferences: UserPreferences | null;
@Column(DataType.JSONB)
notificationSettings: NotificationSettings;
@Default(env.DEFAULT_LANGUAGE)
@IsIn([languages])
@Column
@@ -261,6 +265,35 @@ class User extends ParanoidModel {
// instance methods
/**
* Sets a preference for the users notification settings.
*
* @param type The type of notification event
* @param value Set the preference to true/false
*/
public setNotificationEventType = (
type: NotificationEventType,
value = true
) => {
this.notificationSettings[type] = value;
this.changed("notificationSettings", true);
};
/**
* Returns the current preference for the given notification event type taking
* into account the default system value.
*
* @param type The type of notification event
* @returns The current preference
*/
public subscribedToEventType = (type: NotificationEventType) => {
return (
this.notificationSettings[type] ??
NotificationEventDefaults[type] ??
false
);
};
/**
* User flags are for storing information on a user record that is not visible
* to the user itself.
@@ -541,12 +574,6 @@ class User extends ParanoidModel {
model: User,
options: { transaction: Transaction }
) => {
await NotificationSetting.destroy({
where: {
userId: model.id,
},
transaction: options.transaction,
});
await ApiKey.destroy({
where: {
userId: model.id,
@@ -615,62 +642,6 @@ class User extends ParanoidModel {
}
};
// By default when a user signs up we subscribe them to email notifications
// when documents they created are edited by other team members and onboarding.
// If the user is an admin, they will also be subscribed to export_completed
// notifications.
@AfterCreate
static subscribeToNotifications = async (
model: User,
options: { transaction: Transaction }
) => {
await Promise.all([
NotificationSetting.findOrCreate({
where: {
userId: model.id,
teamId: model.teamId,
event: "documents.update",
},
transaction: options.transaction,
}),
NotificationSetting.findOrCreate({
where: {
userId: model.id,
teamId: model.teamId,
event: "emails.onboarding",
},
transaction: options.transaction,
}),
NotificationSetting.findOrCreate({
where: {
userId: model.id,
teamId: model.teamId,
event: "emails.features",
},
transaction: options.transaction,
}),
NotificationSetting.findOrCreate({
where: {
userId: model.id,
teamId: model.teamId,
event: "emails.invite_accepted",
},
transaction: options.transaction,
}),
]);
if (model.isAdmin) {
await NotificationSetting.findOrCreate({
where: {
userId: model.id,
teamId: model.teamId,
event: "emails.export_completed",
},
transaction: options.transaction,
});
}
};
static getCounts = async function (teamId: string) {
const countSql = `
SELECT

View File

@@ -1,10 +1,10 @@
import { uniqBy } from "lodash";
import { Op } from "sequelize";
import { NotificationEventType } from "@shared/types";
import Logger from "@server/logging/Logger";
import {
User,
Document,
Collection,
NotificationSetting,
Subscription,
Comment,
View,
@@ -15,27 +15,29 @@ export default class NotificationHelper {
* Get the recipients of a notification for a collection event.
*
* @param collection The collection to get recipients for
* @param eventName The event name
* @param eventType The event type
* @returns A list of recipients
*/
public static getCollectionNotificationRecipients = async (
collection: Collection,
eventName: string
): Promise<NotificationSetting[]> => {
// First find all the users that have notifications enabled for this event
eventType: NotificationEventType
): Promise<User[]> => {
// 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({
let recipients = await User.findAll({
where: {
userId: {
id: {
[Op.ne]: collection.createdById,
},
teamId: collection.teamId,
event: eventName,
},
});
// Ensure we only have one recipient per user as a safety measure
return uniqBy(recipients, "userId");
recipients = recipients.filter((recipient) =>
recipient.subscribedToEventType(eventType)
);
return recipients;
};
/**
@@ -43,21 +45,25 @@ export default class NotificationHelper {
*
* @param document The document associated with the comment
* @param comment The comment to get recipients for
* @param eventName The event name
* @param actorId The creator of the comment
* @returns A list of recipients
*/
public static getCommentNotificationRecipients = async (
document: Document,
comment: Comment,
actorId: string
): Promise<NotificationSetting[]> => {
): Promise<User[]> => {
let recipients = await this.getDocumentNotificationRecipients(
document,
"documents.update",
NotificationEventType.UpdateDocument,
actorId,
!comment.parentCommentId
);
recipients = recipients.filter((recipient) =>
recipient.subscribedToEventType(NotificationEventType.CreateComment)
);
if (recipients.length > 0 && comment.parentCommentId) {
const contextComments = await Comment.findAll({
attributes: ["createdById"],
@@ -70,17 +76,17 @@ export default class NotificationHelper {
});
const userIdsInThread = contextComments.map((c) => c.createdById);
recipients = recipients.filter((r) => userIdsInThread.includes(r.userId));
recipients = recipients.filter((r) => userIdsInThread.includes(r.id));
}
const filtered: NotificationSetting[] = [];
const filtered: User[] = [];
for (const recipient of recipients) {
// If this recipient has viewed the document since the comment was made
// then we can avoid sending them a useless notification, yay.
const view = await View.findOne({
where: {
userId: recipient.userId,
userId: recipient.id,
documentId: document.id,
updatedAt: {
[Op.gt]: comment.createdAt,
@@ -91,7 +97,7 @@ export default class NotificationHelper {
if (view) {
Logger.info(
"processor",
`suppressing notification to ${recipient.userId} because doc viewed`
`suppressing notification to ${recipient.id} because doc viewed`
);
} else {
filtered.push(recipient);
@@ -105,7 +111,7 @@ export default class NotificationHelper {
* Get the recipients of a notification for a document event.
*
* @param document The document to get recipients for.
* @param eventName The event name.
* @param eventType The event name.
* @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.
@@ -113,30 +119,33 @@ export default class NotificationHelper {
*/
public static getDocumentNotificationRecipients = async (
document: Document,
eventName: string,
eventType: NotificationEventType,
actorId: string,
onlySubscribers: boolean
): Promise<NotificationSetting[]> => {
): 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 NotificationSetting.scope("withUser").findAll({
let recipients = await User.findAll({
where: {
userId: {
id: {
[Op.ne]: actorId,
},
teamId: document.teamId,
event: eventName,
},
});
recipients = recipients.filter((recipient) =>
recipient.subscribedToEventType(eventType)
);
// Filter further to only those that have a subscription to the document…
if (onlySubscribers) {
const subscriptions = await Subscription.findAll({
attributes: ["userId"],
where: {
userId: recipients.map((recipient) => recipient.user.id),
userId: recipients.map((recipient) => recipient.id),
documentId: document.id,
event: eventName,
event: eventType,
},
});
@@ -145,28 +154,27 @@ export default class NotificationHelper {
);
recipients = recipients.filter((recipient) =>
subscribedUserIds.includes(recipient.user.id)
subscribedUserIds.includes(recipient.id)
);
}
const filtered = [];
for (const recipient of recipients) {
const collectionIds = await recipient.user.collectionIds();
const collectionIds = await recipient.collectionIds();
// 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.
if (
recipient.user.email &&
!recipient.user.isSuspended &&
recipient.email &&
!recipient.isSuspended &&
collectionIds.includes(document.collectionId)
) {
filtered.push(recipient);
}
}
// Ensure we only have one recipient per user as a safety measure
return uniqBy(filtered, "userId");
return filtered;
};
}

View File

@@ -0,0 +1,45 @@
import crypto from "crypto";
import {
NotificationEventDefaults,
NotificationEventType,
} from "@shared/types";
import env from "@server/env";
import User from "../User";
/**
* Helper class for working with notification settings
*/
export default class NotificationSettingsHelper {
/**
* Get the default notification settings for a user
*
* @returns The default notification settings
*/
public static getDefaults() {
return NotificationEventDefaults;
}
/**
* Get the unsubscribe URL for a user and event type. This url allows the user
* to unsubscribe from a specific event without being signed in, for one-click
* links in emails.
*
* @param user The user to unsubscribe
* @param eventType The event type to unsubscribe from
* @returns The unsubscribe URL
*/
public static unsubscribeUrl(user: User, eventType: NotificationEventType) {
return `${
env.URL
}/api/notifications.unsubscribe?token=${this.unsubscribeToken(
user,
eventType
)}&userId=${user.id}&eventType=${eventType}`;
}
public static unsubscribeToken(user: User, eventType: NotificationEventType) {
const hash = crypto.createHash("sha256");
hash.update(`${user.id}-${env.SECRET_KEY}-${eventType}`);
return hash.digest("hex");
}
}

View File

@@ -30,8 +30,6 @@ export { default as IntegrationAuthentication } from "./IntegrationAuthenticatio
export { default as Notification } from "./Notification";
export { default as NotificationSetting } from "./NotificationSetting";
export { default as Pin } from "./Pin";
export { default as Revision } from "./Revision";

View File

@@ -17,7 +17,6 @@ import "./comment";
import "./document";
import "./fileOperation";
import "./integration";
import "./notificationSetting";
import "./pins";
import "./searchQuery";
import "./share";

View File

@@ -1,16 +0,0 @@
import { NotificationSetting, Team, User } from "@server/models";
import { allow } from "./cancan";
allow(User, "createNotificationSetting", Team, (user, team) => {
if (!team || user.teamId !== team.id) {
return false;
}
return true;
});
allow(
User,
["read", "update", "delete"],
NotificationSetting,
(user, setting) => user && user.id === setting?.userId
);

View File

@@ -12,7 +12,6 @@ import presentGroup from "./group";
import presentGroupMembership from "./groupMembership";
import presentIntegration from "./integration";
import presentMembership from "./membership";
import presentNotificationSetting from "./notificationSetting";
import presentPin from "./pin";
import presentPolicies from "./policy";
import presentProviderConfig from "./providerConfig";
@@ -41,7 +40,6 @@ export {
presentGroupMembership,
presentIntegration,
presentMembership,
presentNotificationSetting,
presentPublicTeam,
presentPin,
presentPolicies,

View File

@@ -1,10 +0,0 @@
import { NotificationSetting } from "@server/models";
export default function presentNotificationSetting(
setting: NotificationSetting
) {
return {
id: setting.id,
event: setting.event,
};
}

View File

@@ -1,4 +1,4 @@
import { UserPreferences } from "@shared/types";
import { NotificationSettings, UserPreferences } from "@shared/types";
import env from "@server/env";
import { User } from "@server/models";
@@ -20,6 +20,7 @@ type UserPresentation = {
email?: string | null;
language?: string;
preferences?: UserPreferences | null;
notificationSettings?: NotificationSettings;
};
export default function presentUser(
@@ -43,6 +44,7 @@ export default function presentUser(
userData.email = user.email;
userData.language = user.language || env.DEFAULT_LANGUAGE;
userData.preferences = user.preferences;
userData.notificationSettings = user.notificationSettings;
}
return userData;

View File

@@ -1,7 +1,7 @@
import { NotificationEventType } from "@shared/types";
import DocumentNotificationEmail from "@server/emails/templates/DocumentNotificationEmail";
import {
View,
NotificationSetting,
Subscription,
Event,
Notification,
@@ -15,7 +15,6 @@ import {
import { setupTestDatabase } from "@server/test/support";
import NotificationsProcessor from "./NotificationsProcessor";
jest.mock("@server/emails/templates/DocumentNotificationEmail");
const ip = "127.0.0.1";
setupTestDatabase();
@@ -26,16 +25,18 @@ beforeEach(async () => {
describe("documents.publish", () => {
test("should not send a notification to author", async () => {
const schedule = jest.spyOn(
DocumentNotificationEmail.prototype,
"schedule"
);
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.publish",
});
user.setNotificationEventType(NotificationEventType.PublishDocument);
await user.save();
const processor = new NotificationsProcessor();
await processor.perform({
@@ -49,19 +50,20 @@ describe("documents.publish", () => {
},
ip,
});
expect(DocumentNotificationEmail.schedule).not.toHaveBeenCalled();
expect(schedule).not.toHaveBeenCalled();
});
test("should send a notification to other users in team", async () => {
const schedule = jest.spyOn(
DocumentNotificationEmail.prototype,
"schedule"
);
const user = await buildUser();
const document = await buildDocument({
teamId: user.teamId,
});
await NotificationSetting.create({
userId: user.id,
teamId: user.teamId,
event: "documents.publish",
});
user.setNotificationEventType(NotificationEventType.PublishDocument);
await user.save();
const processor = new NotificationsProcessor();
await processor.perform({
@@ -75,10 +77,14 @@ describe("documents.publish", () => {
},
ip,
});
expect(DocumentNotificationEmail.schedule).toHaveBeenCalled();
expect(schedule).toHaveBeenCalled();
});
test("should send only one notification in a 12-hour window", async () => {
const schedule = jest.spyOn(
DocumentNotificationEmail.prototype,
"schedule"
);
const user = await buildUser();
const document = await buildDocument({
teamId: user.teamId,
@@ -90,11 +96,8 @@ describe("documents.publish", () => {
teamId: user.teamId,
});
await NotificationSetting.create({
userId: recipient.id,
teamId: recipient.teamId,
event: "documents.publish",
});
user.setNotificationEventType(NotificationEventType.PublishDocument);
await user.save();
await Notification.create({
actorId: user.id,
@@ -117,10 +120,14 @@ describe("documents.publish", () => {
},
ip,
});
expect(DocumentNotificationEmail.schedule).not.toHaveBeenCalled();
expect(schedule).not.toHaveBeenCalled();
});
test("should not send a notification to users without collection access", async () => {
const schedule = jest.spyOn(
DocumentNotificationEmail.prototype,
"schedule"
);
const user = await buildUser();
const collection = await buildCollection({
teamId: user.teamId,
@@ -130,11 +137,9 @@ describe("documents.publish", () => {
teamId: user.teamId,
collectionId: collection.id,
});
await NotificationSetting.create({
userId: user.id,
teamId: user.teamId,
event: "documents.publish",
});
user.setNotificationEventType(NotificationEventType.PublishDocument);
await user.save();
const processor = new NotificationsProcessor();
await processor.perform({
name: "documents.publish",
@@ -147,12 +152,16 @@ describe("documents.publish", () => {
},
ip,
});
expect(DocumentNotificationEmail.schedule).not.toHaveBeenCalled();
expect(schedule).not.toHaveBeenCalled();
});
});
describe("revisions.create", () => {
test("should send a notification to other collaborators", async () => {
const schedule = jest.spyOn(
DocumentNotificationEmail.prototype,
"schedule"
);
const document = await buildDocument();
await Revision.createFromDocument(document);
@@ -162,11 +171,7 @@ describe("revisions.create", () => {
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",
});
const processor = new NotificationsProcessor();
await processor.perform({
name: "revisions.create",
@@ -177,10 +182,14 @@ describe("revisions.create", () => {
modelId: revision.id,
ip,
});
expect(DocumentNotificationEmail.schedule).toHaveBeenCalled();
expect(schedule).toHaveBeenCalled();
});
test("should not send a notification if viewed since update", async () => {
const schedule = jest.spyOn(
DocumentNotificationEmail.prototype,
"schedule"
);
const document = await buildDocument();
await Revision.createFromDocument(document);
document.text = "Updated body content";
@@ -189,11 +198,7 @@ describe("revisions.create", () => {
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.create({
userId: collaborator.id,
documentId: document.id,
@@ -209,10 +214,14 @@ describe("revisions.create", () => {
modelId: revision.id,
ip,
});
expect(DocumentNotificationEmail.schedule).not.toHaveBeenCalled();
expect(schedule).not.toHaveBeenCalled();
});
test("should not send a notification to last editor", async () => {
const schedule = jest.spyOn(
DocumentNotificationEmail.prototype,
"schedule"
);
const user = await buildUser();
const document = await buildDocument({
teamId: user.teamId,
@@ -222,11 +231,7 @@ describe("revisions.create", () => {
document.text = "Updated body content";
document.updatedAt = new Date();
const revision = await Revision.createFromDocument(document);
await NotificationSetting.create({
userId: user.id,
teamId: user.teamId,
event: "documents.update",
});
const processor = new NotificationsProcessor();
await processor.perform({
name: "revisions.create",
@@ -237,10 +242,14 @@ describe("revisions.create", () => {
modelId: revision.id,
ip,
});
expect(DocumentNotificationEmail.schedule).not.toHaveBeenCalled();
expect(schedule).not.toHaveBeenCalled();
});
test("should send a notification for subscriptions, even to collaborator", async () => {
const schedule = jest.spyOn(
DocumentNotificationEmail.prototype,
"schedule"
);
const document = await buildDocument();
await Revision.createFromDocument(document);
document.text = "Updated body content";
@@ -253,12 +262,6 @@ describe("revisions.create", () => {
await document.save();
await NotificationSetting.create({
userId: collaborator.id,
teamId: collaborator.teamId,
event: "documents.update",
});
await Subscription.create({
userId: subscriber.id,
documentId: document.id,
@@ -278,7 +281,7 @@ describe("revisions.create", () => {
ip,
});
expect(DocumentNotificationEmail.schedule).toHaveBeenCalled();
expect(schedule).toHaveBeenCalled();
});
test("should create subscriptions for collaborator", async () => {
@@ -327,6 +330,10 @@ describe("revisions.create", () => {
});
test("should not send multiple emails", async () => {
const schedule = jest.spyOn(
DocumentNotificationEmail.prototype,
"schedule"
);
const collaborator0 = await buildUser();
const collaborator1 = await buildUser({ teamId: collaborator0.teamId });
const collaborator2 = await buildUser({ teamId: collaborator0.teamId });
@@ -370,10 +377,14 @@ describe("revisions.create", () => {
// This should send out 2 emails, one for each collaborator that did not
// participate in the edit
expect(DocumentNotificationEmail.schedule).toHaveBeenCalledTimes(2);
expect(schedule).toHaveBeenCalledTimes(2);
});
test("should not create subscriptions if previously unsubscribed", async () => {
const schedule = jest.spyOn(
DocumentNotificationEmail.prototype,
"schedule"
);
const collaborator0 = await buildUser();
const collaborator1 = await buildUser({ teamId: collaborator0.teamId });
const collaborator2 = await buildUser({ teamId: collaborator0.teamId });
@@ -429,10 +440,14 @@ describe("revisions.create", () => {
// One notification as one collaborator performed edit and the other is
// unsubscribed
expect(DocumentNotificationEmail.schedule).toHaveBeenCalledTimes(1);
expect(schedule).toHaveBeenCalledTimes(1);
});
test("should send a notification for subscriptions to non-collaborators", async () => {
const schedule = jest.spyOn(
DocumentNotificationEmail.prototype,
"schedule"
);
const document = await buildDocument();
const collaborator = await buildUser({ teamId: document.teamId });
const subscriber = await buildUser({ teamId: document.teamId });
@@ -446,12 +461,6 @@ describe("revisions.create", () => {
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({
@@ -473,10 +482,14 @@ describe("revisions.create", () => {
ip,
});
expect(DocumentNotificationEmail.schedule).toHaveBeenCalled();
expect(schedule).toHaveBeenCalled();
});
test("should not send a notification for subscriptions to collaborators if unsubscribed", async () => {
const schedule = jest.spyOn(
DocumentNotificationEmail.prototype,
"schedule"
);
const document = await buildDocument();
await Revision.createFromDocument(document);
document.text = "Updated body content";
@@ -490,12 +503,6 @@ describe("revisions.create", () => {
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({
@@ -520,10 +527,14 @@ describe("revisions.create", () => {
});
// Should send notification to `collaborator` and not `subscriber`.
expect(DocumentNotificationEmail.schedule).toHaveBeenCalledTimes(1);
expect(schedule).toHaveBeenCalledTimes(1);
});
test("should not send a notification for subscriptions to members outside of the team", async () => {
const schedule = jest.spyOn(
DocumentNotificationEmail.prototype,
"schedule"
);
const document = await buildDocument();
await Revision.createFromDocument(document);
document.text = "Updated body content";
@@ -540,12 +551,6 @@ describe("revisions.create", () => {
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,
@@ -570,20 +575,20 @@ describe("revisions.create", () => {
});
// Should send notification to `collaborator` and not `subscriber`.
expect(DocumentNotificationEmail.schedule).toHaveBeenCalledTimes(1);
expect(schedule).toHaveBeenCalledTimes(1);
});
test("should not send a notification if viewed since update", async () => {
const schedule = jest.spyOn(
DocumentNotificationEmail.prototype,
"schedule"
);
const document = await buildDocument();
const revision = await Revision.createFromDocument(document);
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.create({
userId: collaborator.id,
documentId: document.id,
@@ -600,10 +605,14 @@ describe("revisions.create", () => {
modelId: revision.id,
ip,
});
expect(DocumentNotificationEmail.schedule).not.toHaveBeenCalled();
expect(schedule).not.toHaveBeenCalled();
});
test("should not send a notification to last editor", async () => {
const schedule = jest.spyOn(
DocumentNotificationEmail.prototype,
"schedule"
);
const user = await buildUser();
const document = await buildDocument({
teamId: user.teamId,
@@ -611,11 +620,6 @@ describe("revisions.create", () => {
});
const revision = await Revision.createFromDocument(document);
await NotificationSetting.create({
userId: user.id,
teamId: user.teamId,
event: "documents.update",
});
const processor = new NotificationsProcessor();
await processor.perform({
name: "revisions.create",
@@ -626,6 +630,6 @@ describe("revisions.create", () => {
modelId: revision.id,
ip,
});
expect(DocumentNotificationEmail.schedule).not.toHaveBeenCalled();
expect(schedule).not.toHaveBeenCalled();
});
});

View File

@@ -1,10 +1,11 @@
import { subHours } from "date-fns";
import { differenceBy } from "lodash";
import { Op } from "sequelize";
import { NotificationEventType } from "@shared/types";
import { Minute } from "@shared/utils/time";
import subscriptionCreator from "@server/commands/subscriptionCreator";
import { sequelize } from "@server/database/sequelize";
import CollectionNotificationEmail from "@server/emails/templates/CollectionNotificationEmail";
import CollectionCreatedEmail from "@server/emails/templates/CollectionCreatedEmail";
import DocumentNotificationEmail from "@server/emails/templates/DocumentNotificationEmail";
import MentionNotificationEmail from "@server/emails/templates/MentionNotificationEmail";
import env from "@server/env";
@@ -89,7 +90,12 @@ export default class NotificationsProcessor extends BaseProcessor {
User.findByPk(mention.modelId),
User.findByPk(mention.actorId),
]);
if (recipient && actor && recipient.id !== actor.id) {
if (
recipient &&
actor &&
recipient.id !== actor.id &&
recipient.subscribedToEventType(NotificationEventType.Mentioned)
) {
const notification = await Notification.create({
event: event.name,
userId: recipient.id,
@@ -98,7 +104,7 @@ export default class NotificationsProcessor extends BaseProcessor {
documentId: document.id,
});
userIdsSentNotifications.push(recipient.id);
await MentionNotificationEmail.schedule(
await new MentionNotificationEmail(
{
to: recipient.email,
documentId: event.documentId,
@@ -107,44 +113,42 @@ export default class NotificationsProcessor extends BaseProcessor {
mentionId: mention.id,
},
{ notificationId: notification.id }
);
).schedule();
}
}
const recipients = (
await NotificationHelper.getDocumentNotificationRecipients(
document,
"documents.publish",
NotificationEventType.PublishDocument,
document.lastModifiedById,
false
)
).filter(
(recipient) => !userIdsSentNotifications.includes(recipient.userId)
);
).filter((recipient) => !userIdsSentNotifications.includes(recipient.id));
for (const recipient of recipients) {
const notify = await this.shouldNotify(document, recipient.user);
const notify = await this.shouldNotify(document, recipient);
if (notify) {
const notification = await Notification.create({
event: event.name,
userId: recipient.user.id,
userId: recipient.id,
actorId: document.updatedBy.id,
teamId: team.id,
documentId: document.id,
});
await DocumentNotificationEmail.schedule(
await new DocumentNotificationEmail(
{
to: recipient.user.email,
eventName: "published",
to: recipient.email,
userId: recipient.id,
eventType: NotificationEventType.PublishDocument,
documentId: document.id,
teamUrl: team.url,
actorName: document.updatedBy.name,
collectionName: collection.name,
unsubscribeUrl: recipient.unsubscribeUrl,
},
{ notificationId: notification.id }
);
).schedule();
}
}
}
@@ -175,7 +179,12 @@ export default class NotificationsProcessor extends BaseProcessor {
User.findByPk(mention.modelId),
User.findByPk(mention.actorId),
]);
if (recipient && actor && recipient.id !== actor.id) {
if (
recipient &&
actor &&
recipient.id !== actor.id &&
recipient.subscribedToEventType(NotificationEventType.Mentioned)
) {
const notification = await Notification.create({
event: event.name,
userId: recipient.id,
@@ -184,7 +193,7 @@ export default class NotificationsProcessor extends BaseProcessor {
documentId: document.id,
});
userIdsSentNotifications.push(recipient.id);
await MentionNotificationEmail.schedule(
await new MentionNotificationEmail(
{
to: recipient.email,
documentId: event.documentId,
@@ -193,20 +202,18 @@ export default class NotificationsProcessor extends BaseProcessor {
mentionId: mention.id,
},
{ notificationId: notification.id }
);
).schedule();
}
}
const recipients = (
await NotificationHelper.getDocumentNotificationRecipients(
document,
"documents.update",
NotificationEventType.UpdateDocument,
document.lastModifiedById,
true
)
).filter(
(recipient) => !userIdsSentNotifications.includes(recipient.userId)
);
).filter((recipient) => !userIdsSentNotifications.includes(recipient.id));
if (!recipients.length) {
return;
}
@@ -223,30 +230,30 @@ export default class NotificationsProcessor extends BaseProcessor {
}
for (const recipient of recipients) {
const notify = await this.shouldNotify(document, recipient.user);
const notify = await this.shouldNotify(document, recipient);
if (notify) {
const notification = await Notification.create({
event: event.name,
userId: recipient.user.id,
userId: recipient.id,
actorId: document.updatedBy.id,
teamId: team.id,
documentId: document.id,
});
await DocumentNotificationEmail.schedule(
await new DocumentNotificationEmail(
{
to: recipient.user.email,
eventName: "updated",
to: recipient.email,
userId: recipient.id,
eventType: NotificationEventType.UpdateDocument,
documentId: document.id,
teamUrl: team.url,
actorName: document.updatedBy.name,
collectionName: collection.name,
unsubscribeUrl: recipient.unsubscribeUrl,
content,
},
{ notificationId: notification.id }
);
).schedule();
}
}
}
@@ -262,21 +269,20 @@ export default class NotificationsProcessor extends BaseProcessor {
const recipients = await NotificationHelper.getCollectionNotificationRecipients(
collection,
event.name
NotificationEventType.CreateCollection
);
for (const recipient of recipients) {
// Suppress notifications for suspended users
if (recipient.user.isSuspended || !recipient.user.email) {
if (recipient.isSuspended || !recipient.email) {
continue;
}
await CollectionNotificationEmail.schedule({
to: recipient.user.email,
eventName: "created",
await new CollectionCreatedEmail({
to: recipient.email,
userId: recipient.id,
collectionId: collection.id,
unsubscribeUrl: recipient.unsubscribeUrl,
});
}).schedule();
}
}

View File

@@ -1,4 +1,5 @@
import { Node } from "prosemirror-model";
import { NotificationEventType } from "@shared/types";
import subscriptionCreator from "@server/commands/subscriptionCreator";
import { sequelize } from "@server/database/sequelize";
import { schema } from "@server/editor";
@@ -66,7 +67,12 @@ export default class CommentCreatedNotificationTask extends BaseTask<
User.findByPk(mention.modelId),
User.findByPk(mention.actorId),
]);
if (recipient && actor && recipient.id !== actor.id) {
if (
recipient &&
actor &&
recipient.id !== actor.id &&
recipient.subscribedToEventType(NotificationEventType.Mentioned)
) {
const notification = await Notification.create({
event: event.name,
userId: recipient.id,
@@ -75,9 +81,11 @@ export default class CommentCreatedNotificationTask extends BaseTask<
documentId: document.id,
});
userIdsSentNotifications.push(recipient.id);
await CommentCreatedEmail.schedule(
await new CommentCreatedEmail(
{
to: recipient.email,
userId: recipient.id,
documentId: document.id,
teamUrl: team.url,
isReply: !!comment.parentCommentId,
@@ -87,7 +95,7 @@ export default class CommentCreatedNotificationTask extends BaseTask<
collectionName: document.collection?.name,
},
{ notificationId: notification.id }
);
).schedule();
}
}
@@ -97,24 +105,20 @@ export default class CommentCreatedNotificationTask extends BaseTask<
comment,
comment.createdById
)
).filter(
(recipient) => !userIdsSentNotifications.includes(recipient.userId)
);
if (!recipients.length) {
return;
}
).filter((recipient) => !userIdsSentNotifications.includes(recipient.id));
for (const recipient of recipients) {
const notification = await Notification.create({
event: event.name,
userId: recipient.user.id,
userId: recipient.id,
actorId: comment.createdById,
teamId: team.id,
documentId: document.id,
});
await CommentCreatedEmail.schedule(
await new CommentCreatedEmail(
{
to: recipient.user.email,
to: recipient.email,
userId: recipient.id,
documentId: document.id,
teamUrl: team.url,
isReply: !!comment.parentCommentId,
@@ -122,10 +126,9 @@ export default class CommentCreatedNotificationTask extends BaseTask<
commentId: comment.id,
content,
collectionName: document.collection?.name,
unsubscribeUrl: recipient.unsubscribeUrl,
},
{ notificationId: notification.id }
);
).schedule();
}
}

View File

@@ -1,6 +1,6 @@
import fs from "fs";
import { truncate } from "lodash";
import { FileOperationState } from "@shared/types";
import { FileOperationState, NotificationEventType } from "@shared/types";
import ExportFailureEmail from "@server/emails/templates/ExportFailureEmail";
import ExportSuccessEmail from "@server/emails/templates/ExportSuccessEmail";
import Logger from "@server/logging/Logger";
@@ -70,24 +70,28 @@ export default abstract class ExportTask extends BaseTask<Props> {
url,
});
await ExportSuccessEmail.schedule({
to: user.email,
userId: user.id,
id: fileOperation.id,
teamUrl: team.url,
teamId: team.id,
});
if (user.subscribedToEventType(NotificationEventType.ExportCompleted)) {
await new ExportSuccessEmail({
to: user.email,
userId: user.id,
id: fileOperation.id,
teamUrl: team.url,
teamId: team.id,
}).schedule();
}
} catch (error) {
await this.updateFileOperation(fileOperation, {
state: FileOperationState.Error,
error,
});
await ExportFailureEmail.schedule({
to: user.email,
userId: user.id,
teamUrl: team.url,
teamId: team.id,
});
if (user.subscribedToEventType(NotificationEventType.ExportCompleted)) {
await new ExportFailureEmail({
to: user.email,
userId: user.id,
teamUrl: team.url,
teamId: team.id,
}).schedule();
}
throw error;
}
}

View File

@@ -8,7 +8,7 @@ setupTestDatabase();
describe("InviteReminderTask", () => {
it("should not destroy documents not deleted", async () => {
const spy = jest.spyOn(InviteReminderEmail, "schedule");
const spy = jest.spyOn(InviteReminderEmail.prototype, "schedule");
// too old
await buildInvite({

View File

@@ -42,14 +42,14 @@ export default class InviteReminderTask extends BaseTask<Props> {
invitedBy &&
user.getFlag(UserFlag.InviteReminderSent) === 0
) {
await InviteReminderEmail.schedule({
await new InviteReminderEmail({
to: user.email,
name: user.name,
actorName: invitedBy.name,
actorEmail: invitedBy.email,
teamName: user.team.name,
teamUrl: user.team.url,
});
}).schedule();
user.incrementFlag(UserFlag.InviteReminderSent);
await user.save({ transaction });

View File

@@ -24,7 +24,7 @@ import groups from "./groups";
import integrations from "./integrations";
import apiWrapper from "./middlewares/apiWrapper";
import editor from "./middlewares/editor";
import notificationSettings from "./notificationSettings";
import notifications from "./notifications";
import pins from "./pins";
import revisions from "./revisions";
import searches from "./searches";
@@ -81,7 +81,7 @@ router.use("/", stars.routes());
router.use("/", subscriptions.routes());
router.use("/", teams.routes());
router.use("/", integrations.routes());
router.use("/", notificationSettings.routes());
router.use("/", notifications.routes());
router.use("/", attachments.routes());
router.use("/", cron.routes());
router.use("/", groups.routes());

View File

@@ -1,91 +0,0 @@
import Router from "koa-router";
import env from "@server/env";
import auth from "@server/middlewares/authentication";
import { Team, NotificationSetting } from "@server/models";
import { authorize } from "@server/policies";
import { presentNotificationSetting } from "@server/presenters";
import { APIContext } from "@server/types";
import { assertPresent, assertUuid } from "@server/validation";
const router = new Router();
router.post("notificationSettings.create", auth(), async (ctx: APIContext) => {
const { event } = ctx.request.body;
assertPresent(event, "event is required");
const { user } = ctx.state.auth;
authorize(user, "createNotificationSetting", user.team);
const [setting] = await NotificationSetting.findOrCreate({
where: {
userId: user.id,
teamId: user.teamId,
event,
},
});
ctx.body = {
data: presentNotificationSetting(setting),
};
});
router.post("notificationSettings.list", auth(), async (ctx: APIContext) => {
const { user } = ctx.state.auth;
const settings = await NotificationSetting.findAll({
where: {
userId: user.id,
},
});
ctx.body = {
data: settings.map(presentNotificationSetting),
};
});
router.post("notificationSettings.delete", auth(), async (ctx: APIContext) => {
const { id } = ctx.request.body;
assertUuid(id, "id is required");
const { user } = ctx.state.auth;
const setting = await NotificationSetting.findByPk(id);
authorize(user, "delete", setting);
await setting.destroy();
ctx.body = {
success: true,
};
});
const handleUnsubscribe = async (ctx: APIContext) => {
const { id, token } = (ctx.method === "POST"
? ctx.request.body
: ctx.request.query) as {
id?: string;
token?: string;
};
assertUuid(id, "id is required");
assertPresent(token, "token is required");
const setting = await NotificationSetting.findByPk(id, {
include: [
{
model: Team,
required: true,
as: "team",
},
],
});
if (setting && setting.unsubscribeToken === token) {
await setting.destroy();
ctx.redirect(`${setting.team.url}/settings/notifications?success`);
return;
}
ctx.redirect(`${env.URL}?notice=invalid-auth`);
};
router.get("notificationSettings.unsubscribe", handleUnsubscribe);
router.post("notificationSettings.unsubscribe", handleUnsubscribe);
export default router;

View File

@@ -0,0 +1 @@
export { default } from "./notifications";

View File

@@ -0,0 +1,52 @@
import Router from "koa-router";
import { NotificationEventType } from "@shared/types";
import env from "@server/env";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { User } from "@server/models";
import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper";
import { APIContext } from "@server/types";
import * as T from "./schema";
const router = new Router();
const handleUnsubscribe = async (
ctx: APIContext<T.NotificationsUnsubscribeReq>
) => {
const eventType = (ctx.input.body.eventType ??
ctx.input.query.eventType) as NotificationEventType;
const userId = (ctx.input.body.userId ?? ctx.input.query.userId) as string;
const token = (ctx.input.body.token ?? ctx.input.query.token) as string;
const user = await User.scope("withTeam").findByPk(userId, {
rejectOnEmpty: true,
});
const unsubscribeToken = NotificationSettingsHelper.unsubscribeToken(
user,
eventType
);
if (unsubscribeToken !== token) {
ctx.redirect(`${env.URL}?notice=invalid-auth`);
return;
}
user.setNotificationEventType(eventType, false);
await user.save();
ctx.redirect(`${user.team.url}/settings/notifications?success`);
};
router.get(
"notifications.unsubscribe",
validate(T.NotificationsUnsubscribeSchema),
transaction(),
handleUnsubscribe
);
router.post(
"notifications.unsubscribe",
validate(T.NotificationsUnsubscribeSchema),
transaction(),
handleUnsubscribe
);
export default router;

View File

@@ -0,0 +1,44 @@
import { isEmpty } from "lodash";
import { z } from "zod";
import { NotificationEventType } from "@shared/types";
export const NotificationSettingsCreateSchema = z.object({
body: z.object({
eventType: z.nativeEnum(NotificationEventType),
}),
});
export type NotificationSettingsCreateReq = z.infer<
typeof NotificationSettingsCreateSchema
>;
export const NotificationSettingsDeleteSchema = z.object({
body: z.object({
eventType: z.nativeEnum(NotificationEventType),
}),
});
export type NotificationSettingsDeleteReq = z.infer<
typeof NotificationSettingsDeleteSchema
>;
export const NotificationsUnsubscribeSchema = z
.object({
body: z.object({
userId: z.string().uuid().optional(),
token: z.string().optional(),
eventType: z.nativeEnum(NotificationEventType).optional(),
}),
query: z.object({
userId: z.string().uuid().optional(),
token: z.string().optional(),
eventType: z.nativeEnum(NotificationEventType).optional(),
}),
})
.refine((req) => !(isEmpty(req.body.userId) && isEmpty(req.query.userId)), {
message: "userId is required",
});
export type NotificationsUnsubscribeReq = z.infer<
typeof NotificationsUnsubscribeSchema
>;

View File

@@ -14,6 +14,7 @@ exports[`#users.activate should activate a suspended user 1`] = `
"language": "en_US",
"lastActiveAt": null,
"name": "User 1",
"notificationSettings": {},
"preferences": null,
"updatedAt": "2018-01-02T00:00:00.000Z",
},
@@ -70,6 +71,7 @@ exports[`#users.demote should demote an admin 1`] = `
"language": "en_US",
"lastActiveAt": null,
"name": "User 1",
"notificationSettings": {},
"preferences": null,
"updatedAt": "2018-01-02T00:00:00.000Z",
},
@@ -108,6 +110,7 @@ exports[`#users.demote should demote an admin to member 1`] = `
"language": "en_US",
"lastActiveAt": null,
"name": "User 1",
"notificationSettings": {},
"preferences": null,
"updatedAt": "2018-01-02T00:00:00.000Z",
},
@@ -146,6 +149,7 @@ exports[`#users.demote should demote an admin to viewer 1`] = `
"language": "en_US",
"lastActiveAt": null,
"name": "User 1",
"notificationSettings": {},
"preferences": null,
"updatedAt": "2018-01-02T00:00:00.000Z",
},
@@ -202,6 +206,7 @@ exports[`#users.promote should promote a new admin 1`] = `
"language": "en_US",
"lastActiveAt": null,
"name": "User 1",
"notificationSettings": {},
"preferences": null,
"updatedAt": "2018-01-02T00:00:00.000Z",
},
@@ -267,6 +272,7 @@ exports[`#users.suspend should suspend an user 1`] = `
"language": "en_US",
"lastActiveAt": null,
"name": "User 1",
"notificationSettings": {},
"preferences": null,
"updatedAt": "2018-01-02T00:00:00.000Z",
},

View File

@@ -0,0 +1 @@
export { default } from "./users";

View File

@@ -0,0 +1,22 @@
import { z } from "zod";
import { NotificationEventType } from "@shared/types";
export const UsersNotificationsSubscribeSchema = z.object({
body: z.object({
eventType: z.nativeEnum(NotificationEventType),
}),
});
export type UsersNotificationsSubscribeReq = z.infer<
typeof UsersNotificationsSubscribeSchema
>;
export const UsersNotificationsUnsubscribeSchema = z.object({
body: z.object({
eventType: z.nativeEnum(NotificationEventType),
}),
});
export type UsersNotificationsUnsubscribeReq = z.infer<
typeof UsersNotificationsUnsubscribeSchema
>;

View File

@@ -17,6 +17,7 @@ import logger from "@server/logging/Logger";
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 { Event, User, Team } from "@server/models";
import { UserFlag, UserRole } from "@server/models/User";
import { can, authorize } from "@server/policies";
@@ -32,7 +33,8 @@ import {
assertKeysIn,
assertBoolean,
} from "@server/validation";
import pagination from "./middlewares/pagination";
import pagination from "../middlewares/pagination";
import * as T from "./schema";
const router = new Router();
const emailEnabled = !!(env.SMTP_HOST || env.ENVIRONMENT === "development");
@@ -389,14 +391,14 @@ router.post(
throw ValidationError("This invite has been sent too many times");
}
await InviteEmail.schedule({
await new InviteEmail({
to: user.email,
name: user.name,
actorName: actor.name,
actorEmail: actor.email,
teamName: actor.team.name,
teamUrl: actor.team.url,
});
}).schedule();
user.incrementFlag(UserFlag.InviteSent);
await user.save({ transaction });
@@ -425,10 +427,10 @@ router.post(
authorize(user, "delete", user);
if (emailEnabled) {
await ConfirmUserDeleteEmail.schedule({
await new ConfirmUserDeleteEmail({
to: user.email,
deleteConfirmationCode: user.deleteConfirmationCode,
});
}).schedule();
}
ctx.body = {
@@ -485,4 +487,42 @@ router.post(
}
);
router.post(
"users.notificationsSubscribe",
auth(),
validate(T.UsersNotificationsSubscribeSchema),
transaction(),
async (ctx: APIContext<T.UsersNotificationsSubscribeReq>) => {
const { eventType } = ctx.input.body;
const { transaction } = ctx.state;
const { user } = ctx.state.auth;
user.setNotificationEventType(eventType, true);
await user.save({ transaction });
ctx.body = {
data: presentUser(user, { includeDetails: true }),
};
}
);
router.post(
"users.notificationsUnsubscribe",
auth(),
validate(T.UsersNotificationsUnsubscribeSchema),
transaction(),
async (ctx: APIContext<T.UsersNotificationsUnsubscribeReq>) => {
const { eventType } = ctx.input.body;
const { transaction } = ctx.state;
const { user } = ctx.state.auth;
user.setNotificationEventType(eventType, false);
await user.save({ transaction });
ctx.body = {
data: presentUser(user, { includeDetails: true }),
};
}
);
export default router;

View File

@@ -0,0 +1,62 @@
import "./bootstrap";
import { QueryTypes } from "sequelize";
import { sequelize } from "@server/database/sequelize";
import { User } 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): Promise<void> => {
console.log(`Backfill user notification settings… page ${page}`);
const users = await User.findAll({
limit,
offset: page * limit,
order: [["createdAt", "ASC"]],
});
for (const user of users) {
try {
const settings = await sequelize.query<{ event: string }>(
`SELECT event FROM notification_settings WHERE "userId" = :userId`,
{
type: QueryTypes.SELECT,
replacements: {
userId: user.id,
},
}
);
const eventTypes = settings.map((setting) => setting.event);
user.notificationSettings = {};
for (const eventType of eventTypes) {
user.notificationSettings[eventType] = true;
user.changed("notificationSettings", true);
}
await user.save({
hooks: false,
silent: true,
});
} catch (err) {
console.error(`Failed at ${user.id}:`, err);
continue;
}
}
return users.length === limit ? work(page + 1) : undefined;
};
await work(page);
if (exit) {
console.log("Backfill complete");
process.exit(0);
}
} // In the test suite we import the script rather than run via node CLI
if (process.env.NODE_ENV !== "test") {
main(true);
}

View File

@@ -732,7 +732,11 @@
"Filter": "Filter",
"Receive a notification whenever a new document is published": "Receive a notification whenever a new document is published",
"Document updated": "Document updated",
"Receive a notification when a document you created is edited": "Receive a notification when a document you created is edited",
"Receive a notification when a document you are subscribed to is edited": "Receive a notification when a document you are subscribed to is edited",
"Comment posted": "Comment posted",
"Receive a notification when a document you are subscribed to or a thread you participated in receives a comment": "Receive a notification when a document you are subscribed to or a thread you participated in receives a comment",
"Mentioned": "Mentioned",
"Receive a notification when someone mentions you in a document or comment": "Receive a notification when someone mentions you in a document or comment",
"Collection created": "Collection created",
"Receive a notification whenever a new collection is created": "Receive a notification whenever a new collection is created",
"Invite accepted": "Invite accepted",

View File

@@ -159,3 +159,41 @@ export type CollectionSort = {
field: string;
direction: "asc" | "desc";
};
export enum NotificationEventType {
PublishDocument = "documents.publish",
UpdateDocument = "documents.update",
CreateCollection = "collections.create",
CreateComment = "comments.create",
Mentioned = "comments.mentioned",
InviteAccepted = "emails.invite_accepted",
Onboarding = "emails.onboarding",
Features = "emails.features",
ExportCompleted = "emails.export_completed",
}
export enum NotificationChannelType {
App = "app",
Email = "email",
Chat = "chat",
}
export type NotificationSettings = {
[key in NotificationEventType]?:
| {
[key in NotificationChannelType]?: boolean;
}
| boolean;
};
export const NotificationEventDefaults = {
[NotificationEventType.PublishDocument]: false,
[NotificationEventType.UpdateDocument]: true,
[NotificationEventType.CreateCollection]: false,
[NotificationEventType.CreateComment]: true,
[NotificationEventType.Mentioned]: true,
[NotificationEventType.InviteAccepted]: true,
[NotificationEventType.Onboarding]: true,
[NotificationEventType.Features]: true,
[NotificationEventType.ExportCompleted]: true,
};