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:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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} />
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
45
server/models/helpers/NotificationSettingsHelper.ts
Normal file
45
server/models/helpers/NotificationSettingsHelper.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -17,7 +17,6 @@ import "./comment";
|
||||
import "./document";
|
||||
import "./fileOperation";
|
||||
import "./integration";
|
||||
import "./notificationSetting";
|
||||
import "./pins";
|
||||
import "./searchQuery";
|
||||
import "./share";
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { NotificationSetting } from "@server/models";
|
||||
|
||||
export default function presentNotificationSetting(
|
||||
setting: NotificationSetting
|
||||
) {
|
||||
return {
|
||||
id: setting.id,
|
||||
event: setting.event,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
1
server/routes/api/notifications/index.ts
Normal file
1
server/routes/api/notifications/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "./notifications";
|
||||
52
server/routes/api/notifications/notifications.ts
Normal file
52
server/routes/api/notifications/notifications.ts
Normal 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;
|
||||
44
server/routes/api/notifications/schema.ts
Normal file
44
server/routes/api/notifications/schema.ts
Normal 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
|
||||
>;
|
||||
@@ -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",
|
||||
},
|
||||
1
server/routes/api/users/index.ts
Normal file
1
server/routes/api/users/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "./users";
|
||||
22
server/routes/api/users/schema.ts
Normal file
22
server/routes/api/users/schema.ts
Normal 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
|
||||
>;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user