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

296 lines
7.8 KiB
TypeScript

import type { TFunction } from "i18next";
import { action, computed, observable } from "mobx";
import type { NotificationData } from "@shared/types";
import { NotificationEventType } from "@shared/types";
import {
collectionPath,
commentPath,
documentPath,
settingsPath,
} from "~/utils/routeHelpers";
import Collection from "./Collection";
import Comment from "./Comment";
import Document from "./Document";
import User from "./User";
import Model from "./base/Model";
import Field from "./decorators/Field";
import Relation from "./decorators/Relation";
export type NotificationFilter =
| "all"
| "mentions"
| "comments"
| "reactions"
| "documents"
| "collections"
| "system";
class Notification extends Model {
static modelName = "Notification";
static filterCategories: Record<NotificationFilter, NotificationEventType[]> =
{
all: [],
mentions: [
NotificationEventType.MentionedInDocument,
NotificationEventType.MentionedInComment,
NotificationEventType.GroupMentionedInDocument,
NotificationEventType.GroupMentionedInComment,
],
comments: [
NotificationEventType.CreateComment,
NotificationEventType.ResolveComment,
NotificationEventType.ReactionsCreate,
],
reactions: [NotificationEventType.ReactionsCreate],
documents: [
NotificationEventType.PublishDocument,
NotificationEventType.UpdateDocument,
NotificationEventType.CreateRevision,
NotificationEventType.AddUserToDocument,
NotificationEventType.RequestDocumentAccess,
],
collections: [
NotificationEventType.CreateCollection,
NotificationEventType.AddUserToCollection,
],
system: [
NotificationEventType.InviteAccepted,
NotificationEventType.Onboarding,
NotificationEventType.Features,
NotificationEventType.ExportCompleted,
],
};
/**
* The date the notification was marked as read.
*/
@Field
@observable
viewedAt: Date | null;
/**
* The date the notification was archived.
*/
@Field
@observable
archivedAt: Date | null;
/**
* Request ID on notifications for access requests.
*/
@Field
@observable
accessRequestId?: string;
/**
* Status of the associated access request.
*/
@Field
@observable
accessRequestStatus?: string;
/**
* The user that triggered the notification.
*/
@Relation(() => User)
actor?: User;
/**
* The document ID that the notification is associated with.
*/
documentId?: string;
/**
* The document that the notification is associated with.
*/
@Relation(() => Document, { onDelete: "cascade" })
document?: Document;
/**
* The collection ID that the notification is associated with.
*/
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.
*/
@Relation(() => Comment, { onDelete: "cascade" })
comment?: Comment;
/**
* The type of notification.
*/
event: NotificationEventType;
/**
* Additional data associated with the notification.
*/
data: NotificationData;
/**
* Mark the notification as read or unread
*
* @returns A promise that resolves when the notification has been saved.
*/
@action
toggleRead() {
this.viewedAt = this.viewedAt ? null : new Date();
return this.save();
}
/**
* Mark the notification as read
*
* @returns A promise that resolves when the notification has been saved.
*/
@action
markAsRead() {
if (this.viewedAt) {
return;
}
this.viewedAt = new Date();
return this.save();
}
/**
* Archive the notification
*
* @returns A promise that resolves when the notification has been archived.
*/
@action
archive() {
if (this.archivedAt) {
return;
}
this.archivedAt = new Date();
return this.save();
}
/**
* Returns translated text that describes the notification
*
* @param t - The translation function
* @returns The event text
*/
eventText(t: TFunction): string {
switch (this.event) {
case NotificationEventType.PublishDocument:
return t("published");
case NotificationEventType.UpdateDocument:
case NotificationEventType.CreateRevision:
return t("edited");
case NotificationEventType.CreateCollection:
return t("created the collection");
case NotificationEventType.MentionedInDocument:
case NotificationEventType.MentionedInComment:
return t("mentioned you in");
case NotificationEventType.GroupMentionedInComment:
case NotificationEventType.GroupMentionedInDocument:
return t("mentioned your group in");
case NotificationEventType.CreateComment:
return t("left a comment on");
case NotificationEventType.ResolveComment:
return t("resolved a comment on");
case NotificationEventType.ReactionsCreate:
return t("reacted {{ emoji }} to your comment on", {
emoji: this.data.emoji,
});
case NotificationEventType.AddUserToDocument:
return t("shared");
case NotificationEventType.AddUserToCollection:
return t("invited you to");
case NotificationEventType.RequestDocumentAccess:
if (this.accessRequestStatus === "approved") {
return t("was granted access to");
}
if (this.accessRequestStatus === "dismissed") {
return t("requested access to");
}
return t("is requesting access to");
default:
return this.event;
}
}
/**
* Returns the subject of the notification. This is the title of the associated
* document.
*
* @returns The subject
*/
get subject() {
if (this.documentId) {
return this.document?.title ?? "a document";
}
if (this.collectionId) {
return this.collection?.name ?? "a collection";
}
return "Unknown";
}
/**
* Returns the path to the model associated with the notification that can be
* used with the router.
*
* @returns The router path.
*/
@computed
get path() {
switch (this.event) {
case NotificationEventType.PublishDocument:
case NotificationEventType.UpdateDocument:
case NotificationEventType.CreateRevision: {
return this.document ? documentPath(this.document) : "";
}
case NotificationEventType.AddUserToCollection:
case NotificationEventType.CreateCollection: {
const collection = this.collectionId
? this.store.rootStore.collections.get(this.collectionId)
: undefined;
return collection ? collectionPath(collection) : "";
}
case NotificationEventType.RequestDocumentAccess:
case NotificationEventType.AddUserToDocument:
case NotificationEventType.GroupMentionedInDocument:
case NotificationEventType.MentionedInDocument: {
return this.document?.path;
}
case NotificationEventType.GroupMentionedInComment:
case NotificationEventType.MentionedInComment:
case NotificationEventType.ResolveComment:
case NotificationEventType.CreateComment:
case NotificationEventType.ReactionsCreate: {
return this.document && this.comment
? commentPath(this.document, this.comment)
: this.document?.path;
}
case NotificationEventType.InviteAccepted: {
return settingsPath("members");
}
case NotificationEventType.Onboarding:
case NotificationEventType.Features: {
return "";
}
case NotificationEventType.ExportCompleted: {
return settingsPath("export");
}
default:
this.event satisfies never;
return;
}
}
}
export default Notification;