Files
Salihu b4cbb39f17 feat: request document access (#10825)
* feat: Request document access

Allow users without permission to a document to request access. Notifies
document managers via in-app notification and email; managers can grant
or dismiss the request.

- Adds AccessRequest model, migration, policy, presenter
- Adds accessRequests.create/info/approve/dismiss endpoints
- Adds DocumentAccessRequestNotificationsTask + email
- Adds Error403 request flow with loading state and pending indicator
- Auto-opens notifications popover via ?notifications=true (used in email)
- Adds SplitButton primitive for permission selection in notifications
- Refactors useConsumeQueryParam hook

* refactor

* fix: Make approve/dismiss idempotent on access requests

Return success when the access request has already been dismissed, or
when the user already has document membership at approve time, instead
of throwing 400. Avoids racy double-clicks on notification actions
producing user-visible errors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Minor fixes

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 08:42:47 -04:00

231 lines
7.7 KiB
TypeScript

import { NotificationEventType } from "@shared/types";
import { Minute } from "@shared/utils/time";
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 CommentResolvedEmail from "@server/emails/templates/CommentResolvedEmail";
import DocumentAccessRequestEmail from "@server/emails/templates/DocumentAccessRequestEmail";
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 type { Event, NotificationEvent } from "@server/types";
import BaseProcessor from "./BaseProcessor";
import GroupDocumentMentionedEmail from "@server/emails/templates/GroupDocumentMentionedEmail";
import GroupCommentMentionedEmail from "@server/emails/templates/GroupCommentMentionedEmail";
export default class EmailsProcessor extends BaseProcessor {
static applicableEvents: Event["name"][] = ["notifications.create"];
async perform(event: NotificationEvent) {
const notification = await Notification.scope([
"withTeam",
"withUser",
"withActor",
]).findByPk(event.modelId);
if (!notification) {
return;
}
const notificationId = notification.id;
if (notification.user.isSuspended) {
return;
}
switch (notification.event) {
case NotificationEventType.UpdateDocument:
case NotificationEventType.PublishDocument: {
// No need to delay email here as the notification itself is already delayed
await new DocumentPublishedOrUpdatedEmail(
{
to: notification.user.email,
language: notification.user.language,
userId: notification.userId,
eventType: notification.event,
revisionId: notification.revisionId,
documentId: notification.documentId,
teamUrl: notification.team.url,
actorName: notification.actor.name,
},
{ notificationId }
).schedule();
return;
}
case NotificationEventType.AddUserToDocument: {
await new DocumentSharedEmail(
{
to: notification.user.email,
language: notification.user.language,
userId: notification.userId,
documentId: notification.documentId,
membershipId: notification.membershipId,
teamUrl: notification.team.url,
actorName: notification.actor.name,
},
{ notificationId }
).schedule({
delay: Minute.ms,
});
return;
}
case NotificationEventType.AddUserToCollection: {
await new CollectionSharedEmail(
{
to: notification.user.email,
language: notification.user.language,
userId: notification.userId,
collectionId: notification.collectionId,
teamUrl: notification.team.url,
actorName: notification.actor.name,
},
{ notificationId }
).schedule({
delay: Minute.ms,
});
return;
}
case NotificationEventType.GroupMentionedInDocument: {
await new GroupDocumentMentionedEmail(
{
to: notification.user.email,
language: notification.user.language,
documentId: notification.documentId,
revisionId: notification.revisionId,
groupId: notification.groupId,
teamUrl: notification.team.url,
actorName: notification.actor.name,
},
{ notificationId }
).schedule();
return;
}
case NotificationEventType.MentionedInDocument: {
// No need to delay email here as the notification itself is already delayed
await new DocumentMentionedEmail(
{
to: notification.user.email,
language: notification.user.language,
documentId: notification.documentId,
revisionId: notification.revisionId,
userId: notification.userId,
teamUrl: notification.team.url,
actorName: notification.actor.name,
},
{ notificationId }
).schedule();
return;
}
case NotificationEventType.GroupMentionedInComment: {
await new GroupCommentMentionedEmail(
{
to: notification.user.email,
language: notification.user.language,
userId: notification.userId,
documentId: notification.documentId,
teamUrl: notification.team.url,
actorName: notification.actor.name,
commentId: notification.commentId,
groupId: notification.groupId,
},
{ notificationId }
).schedule({
delay: Minute.ms,
});
return;
}
case NotificationEventType.MentionedInComment: {
await new CommentMentionedEmail(
{
to: notification.user.email,
language: notification.user.language,
userId: notification.userId,
documentId: notification.documentId,
teamUrl: notification.team.url,
actorName: notification.actor.name,
commentId: notification.commentId,
},
{ notificationId: notification.id }
).schedule({
delay: Minute.ms,
});
return;
}
case NotificationEventType.CreateCollection: {
await new CollectionCreatedEmail(
{
to: notification.user.email,
language: notification.user.language,
userId: notification.userId,
collectionId: notification.collectionId,
teamUrl: notification.team.url,
},
{ notificationId: notification.id }
).schedule({
delay: Minute.ms,
});
return;
}
case NotificationEventType.CreateComment: {
await new CommentCreatedEmail(
{
to: notification.user.email,
language: notification.user.language,
userId: notification.userId,
documentId: notification.documentId,
teamUrl: notification.team.url,
actorName: notification.actor.name,
commentId: notification.commentId,
},
{ notificationId: notification.id }
).schedule({
delay: Minute.ms,
});
return;
}
case NotificationEventType.ResolveComment: {
await new CommentResolvedEmail(
{
to: notification.user.email,
language: notification.user.language,
userId: notification.userId,
documentId: notification.documentId,
teamUrl: notification.team.url,
actorName: notification.actor.name,
commentId: notification.commentId,
},
{ notificationId: notification.id }
).schedule({
delay: Minute.ms,
});
return;
}
case NotificationEventType.RequestDocumentAccess: {
await new DocumentAccessRequestEmail(
{
to: notification.user.email,
documentId: notification.documentId,
actorId: notification.actorId,
teamUrl: notification.team.url,
},
{ notificationId: notification.id }
).schedule({
delay: Minute.ms,
});
return;
}
}
}
}