Add notifications for document and collection access (#6460)

* Add notification for added to document

* Add notifications for document and collection access

* Add notification delay

* fix: Collection notifications not appearing

* Add notification settings
This commit is contained in:
Tom Moor
2024-01-31 15:01:27 -08:00
committed by GitHub
parent 5ce8827a8c
commit 47d168a29b
18 changed files with 437 additions and 31 deletions

View File

@@ -7,6 +7,7 @@ import {
documentPath,
settingsPath,
} from "~/utils/routeHelpers";
import Collection from "./Collection";
import Comment from "./Comment";
import Document from "./Document";
import User from "./User";
@@ -57,6 +58,14 @@ class Notification extends Model {
*/
collectionId?: string;
/**
* The collection that the notification is associated with.
*/
@Relation(() => Collection, { onDelete: "cascade" })
collection?: Collection;
commentId?: string;
/**
* The comment that the notification is associated with.
*/
@@ -114,6 +123,10 @@ class Notification extends Model {
return t("mentioned you in");
case NotificationEventType.CreateComment:
return t("left a comment on");
case NotificationEventType.AddUserToDocument:
return t("shared");
case NotificationEventType.AddUserToCollection:
return t("invited you to");
default:
return this.event;
}
@@ -126,7 +139,13 @@ class Notification extends Model {
* @returns The subject
*/
get subject() {
return this.document?.title;
if (this.documentId) {
return this.document?.title ?? "a document";
}
if (this.collectionId) {
return this.collection?.name ?? "a collection";
}
return "Unknown";
}
/**
@@ -142,20 +161,22 @@ class Notification extends Model {
case NotificationEventType.CreateRevision: {
return this.document ? documentPath(this.document) : "";
}
case NotificationEventType.AddUserToCollection:
case NotificationEventType.CreateCollection: {
const collection = this.collectionId
? this.store.rootStore.documents.get(this.collectionId)
: undefined;
return collection ? collectionPath(collection.url) : "";
return collection ? collectionPath(collection.path) : "";
}
case NotificationEventType.AddUserToDocument:
case NotificationEventType.MentionedInDocument: {
return this.document?.url;
return this.document?.path;
}
case NotificationEventType.MentionedInComment:
case NotificationEventType.CreateComment: {
return this.document && this.comment
? commentPath(this.document, this.comment)
: this.document?.url;
: this.document?.path;
}
case NotificationEventType.InviteAccepted: {
return settingsPath("members");

View File

@@ -5,6 +5,7 @@ import {
CheckboxIcon,
CollectionIcon,
CommentIcon,
DocumentIcon,
EditIcon,
EmailIcon,
PublishIcon,
@@ -80,6 +81,22 @@ function Notifications() {
"Receive a notification when someone you invited creates an account"
),
},
{
event: NotificationEventType.AddUserToDocument,
icon: <DocumentIcon />,
title: t("Invited to document"),
description: t(
"Receive a notification when a document is shared with you"
),
},
{
event: NotificationEventType.AddUserToCollection,
icon: <CollectionIcon />,
title: t("Invited to collection"),
description: t(
"Receive a notification when you are given access to a collection"
),
},
{
event: NotificationEventType.ExportCompleted,
icon: <CheckboxIcon checked />,

View File

@@ -450,7 +450,7 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
event,
subscription,
payload: {
id: event.data.membershipId,
id: event.modelId,
model: model && presentMembership(model),
collection: model && presentCollection(model.collection!),
user: model && presentUser(model.user),
@@ -477,7 +477,7 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
event,
subscription,
payload: {
id: event.data.membershipId,
id: event.modelId,
model: model && presentCollectionGroupMembership(model),
collection: model && presentCollection(model.collection!),
group: model && presentGroup(model.group),

View File

@@ -0,0 +1,102 @@
import * as React from "react";
import { CollectionPermission } from "@shared/types";
import { Collection, UserMembership } from "@server/models";
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 = EmailProps & {
userId: string;
collectionId: string;
actorName: string;
teamUrl: string;
};
type BeforeSend = {
collection: Collection;
membership: UserMembership;
};
type Props = InputProps & BeforeSend;
/**
* Email sent to a user when someone adds them to a collection.
*/
export default class CollectionSharedEmail extends BaseEmail<
InputProps,
BeforeSend
> {
protected async beforeSend({ userId, collectionId }: InputProps) {
const collection = await Collection.findByPk(collectionId);
if (!collection) {
return false;
}
const membership = await UserMembership.findOne({
where: {
collectionId,
userId,
},
});
if (!membership) {
return false;
}
return { collection, membership };
}
protected subject({ actorName, collection }: Props) {
return `${actorName} invited you to the “${collection.name}” collection`;
}
protected preview({ actorName }: Props): string {
return `${actorName} invited you to a collection`;
}
protected fromName({ actorName }: Props) {
return actorName;
}
protected renderAsText({ actorName, teamUrl, collection }: Props): string {
return `
${actorName} invited you to the “${collection.name}” collection.
View Document: ${teamUrl}${collection.path}
`;
}
protected render(props: Props) {
const { collection, membership, actorName, teamUrl } = props;
const collectionUrl = `${teamUrl}${collection.path}?ref=notification-email`;
const permission =
membership.permission === CollectionPermission.ReadWrite
? "view and edit"
: CollectionPermission.Admin
? "manage"
: "view";
return (
<EmailTemplate
previewText={this.preview(props)}
goToAction={{ url: collectionUrl, name: "View Collection" }}
>
<Header />
<Body>
<Heading>{collection.name}</Heading>
<p>
{actorName} invited you to {permission} documents in the{" "}
<a href={collectionUrl}>{collection.name}</a> collection.
</p>
<p>
<Button href={collectionUrl}>View Collection</Button>
</p>
</Body>
</EmailTemplate>
);
}
}

View File

@@ -0,0 +1,98 @@
import * as React from "react";
import { DocumentPermission } from "@shared/types";
import { Document, UserMembership } from "@server/models";
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 = EmailProps & {
userId: string;
documentId: string;
actorName: string;
teamUrl: string;
};
type BeforeSend = {
document: Document;
membership: UserMembership;
};
type Props = InputProps & BeforeSend;
/**
* Email sent to a user when someone adds them to a document.
*/
export default class DocumentSharedEmail extends BaseEmail<
InputProps,
BeforeSend
> {
protected async beforeSend({ documentId, userId }: InputProps) {
const document = await Document.unscoped().findByPk(documentId);
if (!document) {
return false;
}
const membership = await UserMembership.findOne({
where: {
documentId,
userId,
},
});
if (!membership) {
return false;
}
return { document, membership };
}
protected subject({ actorName, document }: Props) {
return `${actorName} shared “${document.title}” with you`;
}
protected preview({ actorName }: Props): string {
return `${actorName} shared a document`;
}
protected fromName({ actorName }: Props) {
return actorName;
}
protected renderAsText({ actorName, teamUrl, document }: Props): string {
return `
${actorName} shared “${document.title}” with you.
View Document: ${teamUrl}${document.path}
`;
}
protected render(props: Props) {
const { document, membership, actorName, teamUrl } = props;
const documentUrl = `${teamUrl}${document.path}?ref=notification-email`;
const permission =
membership.permission === DocumentPermission.ReadWrite ? "edit" : "view";
return (
<EmailTemplate
previewText={this.preview(props)}
goToAction={{ url: documentUrl, name: "View Document" }}
>
<Header />
<Body>
<Heading>{document.title}</Heading>
<p>
{actorName} invited you to {permission} the{" "}
<a href={documentUrl}>{document.title}</a> document.
</p>
<p>
<Button href={documentUrl}>View Document</Button>
</p>
</Body>
</EmailTemplate>
);
}
}

View File

@@ -231,7 +231,17 @@ class Collection extends ParanoidModel<
// getters
/**
* The frontend path to this collection.
*
* @deprecated Use `path` instead.
*/
get url(): string {
return this.path;
}
/** The frontend path to this collection. */
get path(): string {
if (!this.name) {
return `/collection/untitled-${this.urlId}`;
}

View File

@@ -310,6 +310,11 @@ class Document extends ParanoidModel<
* @deprecated Use `path` instead.
*/
get url() {
return this.path;
}
/** The frontend path to this document. */
get path() {
if (!this.title) {
return `/doc/untitled-${this.urlId}`;
}
@@ -317,11 +322,6 @@ class Document extends ParanoidModel<
return `/doc/${slugifiedTitle}-${this.urlId}`;
}
/** The frontend path to this document. */
get path() {
return this.url;
}
get tasks() {
return getTasks(this.text || "");
}

View File

@@ -137,6 +137,8 @@ class Event extends IdModel<
"collections.delete",
"collections.move",
"collections.permission_changed",
"collections.add_user",
"collections.remove_user",
"documents.publish",
"documents.unpublish",
"documents.archive",

View File

@@ -72,12 +72,15 @@ import Fix from "./decorators/Fix";
include: [
{
association: "document",
required: false,
},
{
association: "comment",
required: false,
},
{
association: "actor",
required: false,
},
],
}))
@@ -181,7 +184,9 @@ class Notification extends Model<
userId: model.userId,
modelId: model.id,
teamId: model.teamId,
commentId: model.commentId,
documentId: model.documentId,
collectionId: model.collectionId,
actorId: model.actorId,
};

View File

@@ -1,9 +1,11 @@
import { NotificationEventType } from "@shared/types";
import CollectionCreatedEmail from "@server/emails/templates/CollectionCreatedEmail";
import CollectionSharedEmail from "@server/emails/templates/CollectionSharedEmail";
import CommentCreatedEmail from "@server/emails/templates/CommentCreatedEmail";
import CommentMentionedEmail from "@server/emails/templates/CommentMentionedEmail";
import DocumentMentionedEmail from "@server/emails/templates/DocumentMentionedEmail";
import DocumentPublishedOrUpdatedEmail from "@server/emails/templates/DocumentPublishedOrUpdatedEmail";
import DocumentSharedEmail from "@server/emails/templates/DocumentSharedEmail";
import { Notification } from "@server/models";
import { Event, NotificationEvent } from "@server/types";
import BaseProcessor from "./BaseProcessor";
@@ -23,6 +25,10 @@ export default class EmailsProcessor extends BaseProcessor {
const notificationId = notification.id;
if (notification.user.isSuspended) {
return;
}
switch (notification.event) {
case NotificationEventType.UpdateDocument:
case NotificationEventType.PublishDocument: {
@@ -41,6 +47,34 @@ export default class EmailsProcessor extends BaseProcessor {
return;
}
case NotificationEventType.AddUserToDocument: {
await new DocumentSharedEmail(
{
to: notification.user.email,
userId: notification.userId,
documentId: notification.documentId,
teamUrl: notification.team.url,
actorName: notification.actor.name,
},
{ notificationId }
).schedule();
return;
}
case NotificationEventType.AddUserToCollection: {
await new CollectionSharedEmail(
{
to: notification.user.email,
userId: notification.userId,
collectionId: notification.collectionId,
teamUrl: notification.team.url,
actorName: notification.actor.name,
},
{ notificationId }
).schedule();
return;
}
case NotificationEventType.MentionedInDocument: {
await new DocumentMentionedEmail(
{

View File

@@ -5,10 +5,14 @@ import {
Event,
DocumentEvent,
CommentEvent,
CollectionUserEvent,
DocumentUserEvent,
} from "@server/types";
import CollectionAddUserNotificationsTask from "../tasks/CollectionAddUserNotificationsTask";
import CollectionCreatedNotificationsTask from "../tasks/CollectionCreatedNotificationsTask";
import CommentCreatedNotificationsTask from "../tasks/CommentCreatedNotificationsTask";
import CommentUpdatedNotificationsTask from "../tasks/CommentUpdatedNotificationsTask";
import DocumentAddUserNotificationsTask from "../tasks/DocumentAddUserNotificationsTask";
import DocumentPublishedNotificationsTask from "../tasks/DocumentPublishedNotificationsTask";
import RevisionCreatedNotificationsTask from "../tasks/RevisionCreatedNotificationsTask";
import BaseProcessor from "./BaseProcessor";
@@ -16,8 +20,10 @@ import BaseProcessor from "./BaseProcessor";
export default class NotificationsProcessor extends BaseProcessor {
static applicableEvents: Event["name"][] = [
"documents.publish",
"documents.add_user",
"revisions.create",
"collections.create",
"collections.add_user",
"comments.create",
"comments.update",
];
@@ -26,10 +32,14 @@ export default class NotificationsProcessor extends BaseProcessor {
switch (event.name) {
case "documents.publish":
return this.documentPublished(event);
case "documents.add_user":
return this.documentAddUser(event);
case "revisions.create":
return this.revisionCreated(event);
case "collections.create":
return this.collectionCreated(event);
case "collections.add_user":
return this.collectionAddUser(event);
case "comments.create":
return this.commentCreated(event);
case "comments.update":
@@ -51,6 +61,16 @@ export default class NotificationsProcessor extends BaseProcessor {
await DocumentPublishedNotificationsTask.schedule(event);
}
async documentAddUser(event: DocumentUserEvent) {
if (!event.data.isNew || event.userId === event.actorId) {
return;
}
await DocumentAddUserNotificationsTask.schedule(event, {
delay: Minute,
});
}
async revisionCreated(event: RevisionEvent) {
await RevisionCreatedNotificationsTask.schedule(event);
}
@@ -68,6 +88,16 @@ export default class NotificationsProcessor extends BaseProcessor {
await CollectionCreatedNotificationsTask.schedule(event);
}
async collectionAddUser(event: CollectionUserEvent) {
if (!event.data.isNew || event.userId === event.actorId) {
return;
}
await CollectionAddUserNotificationsTask.schedule(event, {
delay: Minute,
});
}
async commentCreated(event: CommentEvent) {
await CommentCreatedNotificationsTask.schedule(event, {
delay: Minute,

View File

@@ -0,0 +1,32 @@
import { NotificationEventType } from "@shared/types";
import { Notification, User } from "@server/models";
import { CollectionUserEvent } from "@server/types";
import BaseTask, { TaskPriority } from "./BaseTask";
export default class CollectionAddUserNotificationsTask extends BaseTask<CollectionUserEvent> {
public async perform(event: CollectionUserEvent) {
const recipient = await User.findByPk(event.userId);
if (!recipient) {
return;
}
if (
!recipient.isSuspended &&
recipient.subscribedToEventType(NotificationEventType.AddUserToCollection)
) {
await Notification.create({
event: NotificationEventType.AddUserToCollection,
userId: event.userId,
actorId: event.actorId,
teamId: event.teamId,
collectionId: event.collectionId,
});
}
}
public get options() {
return {
priority: TaskPriority.Background,
};
}
}

View File

@@ -0,0 +1,32 @@
import { NotificationEventType } from "@shared/types";
import { Notification, User } from "@server/models";
import { DocumentUserEvent } from "@server/types";
import BaseTask, { TaskPriority } from "./BaseTask";
export default class DocumentAddUserNotificationsTask extends BaseTask<DocumentUserEvent> {
public async perform(event: DocumentUserEvent) {
const recipient = await User.findByPk(event.userId);
if (!recipient) {
return;
}
if (
!recipient.isSuspended &&
recipient.subscribedToEventType(NotificationEventType.AddUserToDocument)
) {
await Notification.create({
event: NotificationEventType.AddUserToDocument,
userId: event.userId,
actorId: event.actorId,
teamId: event.teamId,
documentId: event.documentId,
});
}
}
public get options() {
return {
priority: TaskPriority.Background,
};
}
}

View File

@@ -382,6 +382,7 @@ router.post(
router.post(
"collections.add_user",
auth(),
rateLimiter(RateLimiterStrategy.OneHundredPerHour),
transaction(),
validate(T.CollectionsAddUserSchema),
async (ctx: APIContext<T.CollectionsAddUserReq>) => {
@@ -397,28 +398,20 @@ router.post(
const user = await User.findByPk(userId);
authorize(actor, "read", user);
let membership = await UserMembership.findOne({
const [membership, isNew] = await UserMembership.findOrCreate({
where: {
collectionId: id,
userId,
},
defaults: {
permission: permission || user.defaultCollectionPermission,
createdById: actor.id,
},
transaction,
lock: transaction.LOCK.UPDATE,
});
if (!membership) {
membership = await UserMembership.create(
{
collectionId: id,
userId,
permission: permission || user.defaultCollectionPermission,
createdById: actor.id,
},
{
transaction,
}
);
} else if (permission) {
if (permission) {
membership.permission = permission;
await membership.save({ transaction });
}
@@ -427,12 +420,13 @@ router.post(
{
name: "collections.add_user",
userId,
modelId: membership.id,
collectionId: collection.id,
teamId: collection.teamId,
actorId: actor.id,
data: {
name: user.name,
membershipId: membership.id,
isNew,
permission: membership.permission,
},
ip: ctx.request.ip,
},
@@ -482,12 +476,12 @@ router.post(
{
name: "collections.remove_user",
userId,
modelId: membership.id,
collectionId: collection.id,
teamId: collection.teamId,
actorId: actor.id,
data: {
name: user.name,
membershipId: membership.id,
},
ip: ctx.request.ip,
},

View File

@@ -1475,6 +1475,7 @@ router.post(
"documents.add_user",
auth(),
validate(T.DocumentsAddUserSchema),
rateLimiter(RateLimiterStrategy.OneHundredPerHour),
transaction(),
async (ctx: APIContext<T.DocumentsAddUserReq>) => {
const { auth, transaction } = ctx.state;
@@ -1521,7 +1522,7 @@ router.post(
UserMemberships.length ? UserMemberships[0].index : null
);
const [membership] = await UserMembership.findOrCreate({
const [membership, isNew] = await UserMembership.findOrCreate({
where: {
documentId: id,
userId,
@@ -1553,6 +1554,11 @@ router.post(
teamId: document.teamId,
actorId: actor.id,
ip: ctx.request.ip,
data: {
title: document.title,
isNew,
permission: membership.permission,
},
},
{
transaction,

View File

@@ -7,6 +7,7 @@ import {
NavigationNode,
Client,
CollectionPermission,
DocumentPermission,
} from "@shared/types";
import { BaseSchema } from "@server/routes/api/schema";
import { AccountProvisionerResult } from "./commands/accountProvisioner";
@@ -209,15 +210,19 @@ export type FileOperationEvent = BaseEvent & {
export type CollectionUserEvent = BaseEvent & {
name: "collections.add_user" | "collections.remove_user";
userId: string;
modelId: string;
collectionId: string;
data: { name: string; membershipId: string };
data: {
isNew?: boolean;
permission?: CollectionPermission;
};
};
export type CollectionGroupEvent = BaseEvent & {
name: "collections.add_group" | "collections.remove_group";
collectionId: string;
modelId: string;
data: { name: string; membershipId: string };
data: { name: string };
};
export type DocumentUserEvent = BaseEvent & {
@@ -225,6 +230,11 @@ export type DocumentUserEvent = BaseEvent & {
userId: string;
modelId: string;
documentId: string;
data: {
title: string;
isNew?: boolean;
permission?: DocumentPermission;
};
};
export type CollectionEvent = BaseEvent &
@@ -381,7 +391,10 @@ export type NotificationEvent = BaseEvent & {
modelId: string;
teamId: string;
userId: string;
actorId: string;
commentId?: string;
documentId?: string;
collectionId?: string;
};
export type Event =

View File

@@ -459,6 +459,8 @@
"created the collection": "created the collection",
"mentioned you in": "mentioned you in",
"left a comment on": "left a comment on",
"shared": "shared",
"invited you to": "invited you to",
"API token created": "API token created",
"Name your token something that will help you to remember it's use in the future, for example \"local development\", \"production\", or \"continuous integration\".": "Name your token something that will help you to remember it's use in the future, for example \"local development\", \"production\", or \"continuous integration\".",
"The document archive is empty at the moment.": "The document archive is empty at the moment.",
@@ -858,6 +860,10 @@
"Receive a notification whenever a new collection is created": "Receive a notification whenever a new collection is created",
"Invite accepted": "Invite accepted",
"Receive a notification when someone you invited creates an account": "Receive a notification when someone you invited creates an account",
"Invited to document": "Invited to document",
"Receive a notification when a document is shared with you": "Receive a notification when a document is shared with you",
"Invited to collection": "Invited to collection",
"Receive a notification when you are given access to a collection": "Receive a notification when you are given access to a collection",
"Export completed": "Export completed",
"Receive a notification when an export you requested has been completed": "Receive a notification when an export you requested has been completed",
"Getting started": "Getting started",

View File

@@ -203,6 +203,8 @@ export type CollectionSort = {
export enum NotificationEventType {
PublishDocument = "documents.publish",
UpdateDocument = "documents.update",
AddUserToDocument = "documents.add_user",
AddUserToCollection = "collections.add_user",
CreateRevision = "revisions.create",
CreateCollection = "collections.create",
CreateComment = "comments.create",
@@ -239,6 +241,8 @@ export const NotificationEventDefaults = {
[NotificationEventType.Onboarding]: true,
[NotificationEventType.Features]: true,
[NotificationEventType.ExportCompleted]: true,
[NotificationEventType.AddUserToDocument]: true,
[NotificationEventType.AddUserToCollection]: true,
};
export enum UnfurlType {