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:
102
server/emails/templates/CollectionSharedEmail.tsx
Normal file
102
server/emails/templates/CollectionSharedEmail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
98
server/emails/templates/DocumentSharedEmail.tsx
Normal file
98
server/emails/templates/DocumentSharedEmail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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 || "");
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
32
server/queues/tasks/CollectionAddUserNotificationsTask.ts
Normal file
32
server/queues/tasks/CollectionAddUserNotificationsTask.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
32
server/queues/tasks/DocumentAddUserNotificationsTask.ts
Normal file
32
server/queues/tasks/DocumentAddUserNotificationsTask.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user