mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
b4cbb39f17
* 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>
306 lines
6.4 KiB
TypeScript
306 lines
6.4 KiB
TypeScript
import crypto from "node:crypto";
|
|
import type {
|
|
InferAttributes,
|
|
InferCreationAttributes,
|
|
SaveOptions,
|
|
} from "sequelize";
|
|
import {
|
|
Table,
|
|
ForeignKey,
|
|
Column,
|
|
PrimaryKey,
|
|
IsUUID,
|
|
CreatedAt,
|
|
BelongsTo,
|
|
DataType,
|
|
Default,
|
|
AllowNull,
|
|
Scopes,
|
|
AfterCreate,
|
|
DefaultScope,
|
|
} from "sequelize-typescript";
|
|
import type { NotificationData } from "@shared/types";
|
|
import { NotificationEventType } from "@shared/types";
|
|
import { getBaseDomain } from "@shared/utils/domains";
|
|
import env from "@server/env";
|
|
import Model from "@server/models/base/Model";
|
|
import Collection from "./Collection";
|
|
import Comment from "./Comment";
|
|
import Document from "./Document";
|
|
import Event from "./Event";
|
|
import Revision from "./Revision";
|
|
import Team from "./Team";
|
|
import User from "./User";
|
|
import Group from "./Group";
|
|
import Fix from "./decorators/Fix";
|
|
import AccessRequest from "./AccessRequest";
|
|
|
|
let baseDomain;
|
|
|
|
@Scopes(() => ({
|
|
withTeam: {
|
|
include: [
|
|
{
|
|
association: "team",
|
|
required: true,
|
|
},
|
|
],
|
|
},
|
|
withDocument: {
|
|
include: [
|
|
{
|
|
association: "document",
|
|
},
|
|
],
|
|
},
|
|
withComment: {
|
|
include: [
|
|
{
|
|
association: "comment",
|
|
},
|
|
],
|
|
},
|
|
withActor: {
|
|
include: [
|
|
{
|
|
association: "actor",
|
|
required: true,
|
|
},
|
|
],
|
|
},
|
|
withUser: {
|
|
include: [
|
|
{
|
|
association: "user",
|
|
required: true,
|
|
},
|
|
],
|
|
},
|
|
}))
|
|
@DefaultScope(() => ({
|
|
include: [
|
|
{
|
|
association: "document",
|
|
required: false,
|
|
},
|
|
{
|
|
association: "comment",
|
|
required: false,
|
|
},
|
|
{
|
|
association: "actor",
|
|
required: false,
|
|
},
|
|
{
|
|
association: "accessRequest",
|
|
required: false,
|
|
},
|
|
],
|
|
}))
|
|
@Table({
|
|
tableName: "notifications",
|
|
modelName: "notification",
|
|
updatedAt: false,
|
|
})
|
|
@Fix
|
|
class Notification extends Model<
|
|
InferAttributes<Notification>,
|
|
Partial<InferCreationAttributes<Notification>>
|
|
> {
|
|
@IsUUID(4)
|
|
@PrimaryKey
|
|
@Default(DataType.UUIDV4)
|
|
@Column(DataType.UUID)
|
|
id: string;
|
|
|
|
@AllowNull
|
|
@Column
|
|
emailedAt?: Date | null;
|
|
|
|
@AllowNull
|
|
@Column
|
|
viewedAt: Date | null;
|
|
|
|
@AllowNull
|
|
@Column
|
|
archivedAt: Date | null;
|
|
|
|
@CreatedAt
|
|
createdAt: Date;
|
|
|
|
@Column(DataType.JSONB)
|
|
data: NotificationData | null;
|
|
|
|
@Column(DataType.STRING)
|
|
event: NotificationEventType;
|
|
|
|
// associations
|
|
@BelongsTo(() => Group, "groupId")
|
|
group: Group;
|
|
|
|
@AllowNull
|
|
@ForeignKey(() => User)
|
|
@Column(DataType.UUID)
|
|
groupId: string;
|
|
|
|
@BelongsTo(() => User, "userId")
|
|
user: User;
|
|
|
|
@ForeignKey(() => User)
|
|
@Column(DataType.UUID)
|
|
userId: string;
|
|
|
|
@BelongsTo(() => User, "actorId")
|
|
actor: User;
|
|
|
|
@AllowNull
|
|
@ForeignKey(() => User)
|
|
@Column(DataType.UUID)
|
|
actorId: string;
|
|
|
|
@BelongsTo(() => Comment, "commentId")
|
|
comment: Comment;
|
|
|
|
@AllowNull
|
|
@ForeignKey(() => Comment)
|
|
@Column(DataType.UUID)
|
|
commentId: string;
|
|
|
|
@BelongsTo(() => Document, "documentId")
|
|
document: Document;
|
|
|
|
@AllowNull
|
|
@ForeignKey(() => Document)
|
|
@Column(DataType.UUID)
|
|
documentId: string;
|
|
|
|
@BelongsTo(() => Revision, "revisionId")
|
|
revision: Revision;
|
|
|
|
@AllowNull
|
|
@ForeignKey(() => Revision)
|
|
@Column(DataType.UUID)
|
|
revisionId: string;
|
|
|
|
@BelongsTo(() => Collection, "collectionId")
|
|
collection: Collection;
|
|
|
|
@AllowNull
|
|
@ForeignKey(() => Collection)
|
|
@Column(DataType.UUID)
|
|
collectionId: string;
|
|
|
|
@BelongsTo(() => Team, "teamId")
|
|
team: Team;
|
|
|
|
@ForeignKey(() => Team)
|
|
@Column(DataType.UUID)
|
|
teamId: string;
|
|
|
|
@AllowNull
|
|
@Column(DataType.UUID)
|
|
membershipId: string;
|
|
|
|
@BelongsTo(() => AccessRequest, "accessRequestId")
|
|
accessRequest: AccessRequest;
|
|
|
|
@AllowNull
|
|
@ForeignKey(() => AccessRequest)
|
|
@Column(DataType.UUID)
|
|
accessRequestId: string;
|
|
|
|
@AfterCreate
|
|
static async createEvent(
|
|
model: Notification,
|
|
options: SaveOptions<InferAttributes<Notification>>
|
|
) {
|
|
const params = {
|
|
name: "notifications.create",
|
|
userId: model.userId,
|
|
modelId: model.id,
|
|
teamId: model.teamId,
|
|
commentId: model.commentId,
|
|
documentId: model.documentId,
|
|
collectionId: model.collectionId,
|
|
actorId: model.actorId,
|
|
membershipId: model.membershipId,
|
|
groupId: model.groupId,
|
|
};
|
|
|
|
if (options.transaction) {
|
|
options.transaction.afterCommit(() => void Event.schedule(params));
|
|
return;
|
|
}
|
|
await Event.schedule(params);
|
|
}
|
|
|
|
/**
|
|
* Returns a token that can be used to mark this notification as read
|
|
* without being logged in.
|
|
*
|
|
* @returns A string token
|
|
*/
|
|
public get pixelToken() {
|
|
const hash = crypto.createHash("sha256");
|
|
hash.update(`${this.id}-${env.SECRET_KEY}`);
|
|
return hash.digest("hex");
|
|
}
|
|
|
|
/**
|
|
* Returns a URL that can be used to mark this notification as read
|
|
* without being logged in.
|
|
*
|
|
* @returns A URL
|
|
*/
|
|
public get pixelUrl() {
|
|
return `${env.URL}/api/notifications.pixel?token=${this.pixelToken}&id=${this.id}`;
|
|
}
|
|
|
|
/**
|
|
* Returns the message id for the email.
|
|
*
|
|
* @param name Username part of the email address.
|
|
* @returns Email message id.
|
|
*/
|
|
public static emailMessageId(name: string) {
|
|
baseDomain ||= getBaseDomain();
|
|
return `<${name}@${baseDomain}>`;
|
|
}
|
|
|
|
/**
|
|
* Returns the message reference id which will be used to setup the thread chain in email clients.
|
|
*
|
|
* @param notification Notification for which to determine the reference id.
|
|
* @returns Reference id as an array.
|
|
*/
|
|
public static async emailReferences(
|
|
notification: Notification
|
|
): Promise<string[] | undefined> {
|
|
let name: string | undefined;
|
|
|
|
switch (notification.event) {
|
|
case NotificationEventType.PublishDocument:
|
|
case NotificationEventType.UpdateDocument:
|
|
name = `${notification.documentId}-updates`;
|
|
break;
|
|
case NotificationEventType.GroupMentionedInComment:
|
|
case NotificationEventType.GroupMentionedInDocument:
|
|
name = `${notification.documentId}-group-mentions`;
|
|
break;
|
|
case NotificationEventType.MentionedInDocument:
|
|
case NotificationEventType.MentionedInComment:
|
|
name = `${notification.documentId}-mentions`;
|
|
break;
|
|
case NotificationEventType.CreateComment: {
|
|
const comment = await Comment.findByPk(notification.commentId);
|
|
name = `${comment?.parentCommentId ?? comment?.id}-comments`;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return name ? [this.emailMessageId(name)] : undefined;
|
|
}
|
|
}
|
|
|
|
export default Notification;
|