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

@@ -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 =