mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6863fe3698 | |||
| b68997f78a | |||
| ac2d3bf3cb | |||
| 94a8326c68 |
@@ -1,6 +1,10 @@
|
||||
NODE_ENV=test
|
||||
DATABASE_URL=postgres://user:pass@127.0.0.1:5432/outline-test
|
||||
SECRET_KEY=F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
|
||||
UTILS_SECRET=test-utils-secret-key
|
||||
REDIS_URL=redis://localhost:6379
|
||||
URL=http://localhost:3000
|
||||
COLLABORATION_URL=http://localhost:3001
|
||||
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_USERNAME=test
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.createTable("mentions", {
|
||||
id: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
},
|
||||
userId: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: "users",
|
||||
},
|
||||
},
|
||||
documentId: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: "documents",
|
||||
},
|
||||
},
|
||||
mentionedUserId: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: "users",
|
||||
},
|
||||
},
|
||||
mentionType: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
mentionId: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
createdAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
updatedAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
});
|
||||
await queryInterface.addIndex("mentions", ["mentionedUserId"]);
|
||||
await queryInterface.addIndex("mentions", ["documentId"]);
|
||||
await queryInterface.addIndex("mentions", ["mentionId", "mentionType"]);
|
||||
},
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.dropTable("mentions");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -62,6 +62,7 @@ import Group from "./Group";
|
||||
import GroupMembership from "./GroupMembership";
|
||||
import GroupUser from "./GroupUser";
|
||||
import Import from "./Import";
|
||||
import Mention from "./Mention";
|
||||
import Relationship from "./Relationship";
|
||||
import Revision from "./Revision";
|
||||
import Star from "./Star";
|
||||
@@ -668,6 +669,9 @@ class Document extends ArchivableModel<
|
||||
@HasMany(() => Relationship)
|
||||
relationships: Relationship[];
|
||||
|
||||
@HasMany(() => Mention)
|
||||
mentions: Mention[];
|
||||
|
||||
@HasMany(() => Star)
|
||||
starred: Star[];
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { InferAttributes, InferCreationAttributes } from "sequelize";
|
||||
import {
|
||||
DataType,
|
||||
BelongsTo,
|
||||
ForeignKey,
|
||||
Column,
|
||||
Table,
|
||||
} from "sequelize-typescript";
|
||||
import { MentionType } from "@shared/types";
|
||||
import Document from "./Document";
|
||||
import User from "./User";
|
||||
import IdModel from "./base/IdModel";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
@Table({ tableName: "mentions", modelName: "mention" })
|
||||
@Fix
|
||||
class Mention extends IdModel<
|
||||
InferAttributes<Mention>,
|
||||
Partial<InferCreationAttributes<Mention>>
|
||||
> {
|
||||
@BelongsTo(() => User, "userId")
|
||||
user: User;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
userId: string;
|
||||
|
||||
@BelongsTo(() => Document, "documentId")
|
||||
document: Document;
|
||||
|
||||
@ForeignKey(() => Document)
|
||||
@Column(DataType.UUID)
|
||||
documentId: string;
|
||||
|
||||
@BelongsTo(() => User, "mentionedUserId")
|
||||
mentionedUser: User;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
mentionedUserId: string;
|
||||
|
||||
@Column(DataType.STRING)
|
||||
mentionType: MentionType;
|
||||
|
||||
@Column(DataType.STRING)
|
||||
mentionId: string;
|
||||
|
||||
/**
|
||||
* Find all mentions for a user in documents they have access to
|
||||
*
|
||||
* @param userId The user ID to find mentions for
|
||||
* @param user The user to check document access for
|
||||
*/
|
||||
public static async findMentionsForUser(userId: string, user: User) {
|
||||
// Lazy import to avoid circular dependency
|
||||
const { can } = await import("@server/policies");
|
||||
|
||||
const mentions = await this.findAll({
|
||||
where: {
|
||||
mentionedUserId: userId,
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: Document,
|
||||
as: "document",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Filter mentions to only include documents the user has access to
|
||||
const accessibleMentions = [];
|
||||
for (const mention of mentions) {
|
||||
if (mention.document) {
|
||||
const hasAccess = can(user, "read", mention.document);
|
||||
if (hasAccess) {
|
||||
accessibleMentions.push(mention);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return accessibleMentions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all mentions in a specific document
|
||||
*
|
||||
* @param documentId The document ID to find mentions for
|
||||
*/
|
||||
public static async findMentionsInDocument(documentId: string) {
|
||||
return this.findAll({
|
||||
where: {
|
||||
documentId,
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: "mentionedUser",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default Mention;
|
||||
@@ -57,6 +57,7 @@ import Attachment from "./Attachment";
|
||||
import AuthenticationProvider from "./AuthenticationProvider";
|
||||
import Collection from "./Collection";
|
||||
import Group from "./Group";
|
||||
import Mention from "./Mention";
|
||||
import Team from "./Team";
|
||||
import UserAuthentication from "./UserAuthentication";
|
||||
import UserMembership from "./UserMembership";
|
||||
@@ -248,6 +249,9 @@ class User extends ParanoidModel<
|
||||
@HasMany(() => UserAuthentication)
|
||||
authentications: UserAuthentication[];
|
||||
|
||||
@HasMany(() => Mention, "mentionedUserId")
|
||||
mentions: Mention[];
|
||||
|
||||
// getters
|
||||
|
||||
get isSuspended(): boolean {
|
||||
|
||||
@@ -32,6 +32,8 @@ export { default as Integration } from "./Integration";
|
||||
|
||||
export { default as IntegrationAuthentication } from "./IntegrationAuthentication";
|
||||
|
||||
export { default as Mention } from "./Mention";
|
||||
|
||||
export { default as Notification } from "./Notification";
|
||||
|
||||
export { default as OAuthAuthentication } from "./oauth/OAuthAuthentication";
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { parser } from "@server/editor";
|
||||
import { Mention } from "@server/models";
|
||||
import { buildDocument, buildUser } from "@server/test/factories";
|
||||
import MentionsProcessor from "./MentionsProcessor";
|
||||
|
||||
describe("MentionsProcessor", () => {
|
||||
it("should create new mention records", async () => {
|
||||
const user = await buildUser();
|
||||
const mentionedUser = await buildUser({ teamId: user.teamId });
|
||||
const mentionId = uuidv4();
|
||||
const document = await buildDocument({
|
||||
userId: user.id!,
|
||||
teamId: user.teamId!,
|
||||
text: `Hello @[${mentionedUser.name}](mention://${mentionId}/user/${mentionedUser.id})!`,
|
||||
});
|
||||
|
||||
const processor = new MentionsProcessor();
|
||||
await processor.perform({
|
||||
name: "documents.publish",
|
||||
documentId: document.id!,
|
||||
collectionId: document.collectionId!,
|
||||
teamId: document.teamId!,
|
||||
actorId: user.id!,
|
||||
data: {
|
||||
title: document.title,
|
||||
},
|
||||
ip: "127.0.0.1",
|
||||
});
|
||||
|
||||
const mentions = await Mention.findAll({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mentions.length).toBe(1);
|
||||
});
|
||||
|
||||
it("should not create mention records for unpublished documents", async () => {
|
||||
const user = await buildUser();
|
||||
const mentionedUser = await buildUser({ teamId: user.teamId });
|
||||
const mentionId = uuidv4();
|
||||
const document = await buildDocument({
|
||||
userId: user.id!,
|
||||
teamId: user.teamId!,
|
||||
text: `Hello @[${mentionedUser.name}](mention://${mentionId}/user/${mentionedUser.id})!`,
|
||||
publishedAt: null,
|
||||
});
|
||||
|
||||
const processor = new MentionsProcessor();
|
||||
await processor.perform({
|
||||
name: "documents.update",
|
||||
documentId: document.id!,
|
||||
collectionId: document.collectionId!,
|
||||
teamId: document.teamId!,
|
||||
createdAt: new Date().toISOString(),
|
||||
actorId: user.id!,
|
||||
data: {
|
||||
title: document.title,
|
||||
autosave: false,
|
||||
done: true,
|
||||
},
|
||||
ip: "127.0.0.1",
|
||||
});
|
||||
|
||||
const mentions = await Mention.findAll({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mentions.length).toBe(0);
|
||||
});
|
||||
|
||||
it("should update mention records when document is updated", async () => {
|
||||
const user = await buildUser();
|
||||
const mentionedUser = await buildUser({ teamId: user.teamId });
|
||||
const anotherMentionedUser = await buildUser({ teamId: user.teamId });
|
||||
const mentionId1 = uuidv4();
|
||||
const document = await buildDocument({
|
||||
userId: user.id!,
|
||||
teamId: user.teamId!,
|
||||
text: `Hello @[${mentionedUser.name}](mention://${mentionId1}/user/${mentionedUser.id})!`,
|
||||
});
|
||||
|
||||
const processor = new MentionsProcessor();
|
||||
await processor.perform({
|
||||
name: "documents.publish",
|
||||
documentId: document.id!,
|
||||
collectionId: document.collectionId!,
|
||||
teamId: document.teamId!,
|
||||
actorId: user.id!,
|
||||
data: {
|
||||
title: document.title,
|
||||
},
|
||||
ip: "127.0.0.1",
|
||||
});
|
||||
|
||||
// Update document to mention a different user
|
||||
const mentionId2 = uuidv4();
|
||||
const newText = `Hello @[${anotherMentionedUser.name}](mention://${mentionId2}/user/${anotherMentionedUser.id})!`;
|
||||
document.text = newText;
|
||||
document.content = parser.parse(newText)?.toJSON() || document.content;
|
||||
await document.save();
|
||||
|
||||
await processor.perform({
|
||||
name: "documents.update",
|
||||
documentId: document.id!,
|
||||
collectionId: document.collectionId!,
|
||||
teamId: document.teamId!,
|
||||
actorId: user.id!,
|
||||
createdAt: new Date().toISOString(),
|
||||
data: {
|
||||
title: document.title,
|
||||
autosave: false,
|
||||
done: true,
|
||||
},
|
||||
ip: "127.0.0.1",
|
||||
});
|
||||
|
||||
const mentions = await Mention.findAll({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mentions.length).toBe(1);
|
||||
expect(mentions[0].mentionedUserId).toBe(anotherMentionedUser.id);
|
||||
});
|
||||
|
||||
it("should destroy removed mention records", async () => {
|
||||
const user = await buildUser();
|
||||
const mentionedUser = await buildUser({ teamId: user.teamId });
|
||||
const anotherMentionedUser = await buildUser({ teamId: user.teamId });
|
||||
const mentionId1 = uuidv4();
|
||||
const mentionId2 = uuidv4();
|
||||
const document = await buildDocument({
|
||||
userId: user.id!,
|
||||
teamId: user.teamId!,
|
||||
text: `Hello @[${mentionedUser.name}](mention://${mentionId1}/user/${mentionedUser.id}) and @[${anotherMentionedUser.name}](mention://${mentionId2}/user/${anotherMentionedUser.id})!`,
|
||||
});
|
||||
|
||||
const processor = new MentionsProcessor();
|
||||
await processor.perform({
|
||||
name: "documents.publish",
|
||||
documentId: document.id!,
|
||||
collectionId: document.collectionId!,
|
||||
teamId: document.teamId!,
|
||||
actorId: user.id!,
|
||||
data: {
|
||||
title: document.title,
|
||||
},
|
||||
ip: "127.0.0.1",
|
||||
});
|
||||
|
||||
// Update document to remove one mention
|
||||
const mentionId3 = uuidv4();
|
||||
const newText = `Hello @[${mentionedUser.name}](mention://${mentionId3}/user/${mentionedUser.id})!`;
|
||||
document.text = newText;
|
||||
document.content = parser.parse(newText)?.toJSON() || document.content;
|
||||
await document.save();
|
||||
|
||||
await processor.perform({
|
||||
name: "documents.update",
|
||||
documentId: document.id!,
|
||||
collectionId: document.collectionId!,
|
||||
teamId: document.teamId!,
|
||||
actorId: user.id!,
|
||||
createdAt: new Date().toISOString(),
|
||||
data: {
|
||||
title: document.title,
|
||||
autosave: false,
|
||||
done: true,
|
||||
},
|
||||
ip: "127.0.0.1",
|
||||
});
|
||||
|
||||
const mentions = await Mention.findAll({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mentions.length).toBe(1);
|
||||
expect(mentions[0].mentionedUserId).toBe(mentionedUser.id);
|
||||
});
|
||||
|
||||
it("should destroy related mentions", async () => {
|
||||
const user = await buildUser();
|
||||
const mentionedUser = await buildUser({ teamId: user.teamId });
|
||||
const mentionId = uuidv4();
|
||||
const document = await buildDocument({
|
||||
userId: user.id!,
|
||||
teamId: user.teamId!,
|
||||
text: `Hello @[${mentionedUser.name}](mention://${mentionId}/user/${mentionedUser.id})!`,
|
||||
});
|
||||
|
||||
const processor = new MentionsProcessor();
|
||||
await processor.perform({
|
||||
name: "documents.publish",
|
||||
documentId: document.id!,
|
||||
collectionId: document.collectionId!,
|
||||
teamId: document.teamId!,
|
||||
actorId: user.id!,
|
||||
data: {
|
||||
title: document.title,
|
||||
},
|
||||
ip: "127.0.0.1",
|
||||
});
|
||||
|
||||
await processor.perform({
|
||||
name: "documents.delete",
|
||||
documentId: document.id!,
|
||||
collectionId: document.collectionId!,
|
||||
teamId: document.teamId!,
|
||||
actorId: user.id!,
|
||||
data: {
|
||||
title: document.title,
|
||||
},
|
||||
ip: "127.0.0.1",
|
||||
});
|
||||
|
||||
const mentions = await Mention.findAll({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mentions.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Op } from "sequelize";
|
||||
import { MentionType } from "@shared/types";
|
||||
import { Document, Mention, User } from "@server/models";
|
||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
import { Event, DocumentEvent, RevisionEvent } from "@server/types";
|
||||
import BaseProcessor from "./BaseProcessor";
|
||||
|
||||
export default class MentionsProcessor extends BaseProcessor {
|
||||
static applicableEvents: Event["name"][] = [
|
||||
"documents.publish",
|
||||
"documents.update",
|
||||
"documents.delete",
|
||||
];
|
||||
|
||||
async perform(event: DocumentEvent | RevisionEvent) {
|
||||
switch (event.name) {
|
||||
case "documents.publish": {
|
||||
const document = await Document.findByPk(event.documentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
const mentions = DocumentHelper.parseMentions(document, {
|
||||
type: MentionType.User,
|
||||
});
|
||||
await Promise.all(
|
||||
mentions.map(async (mention) => {
|
||||
const mentionedUser = await User.findByPk(mention.modelId);
|
||||
|
||||
if (
|
||||
!mentionedUser ||
|
||||
mentionedUser.id === document.lastModifiedById
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Mention.findOrCreate({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
mentionedUserId: mentionedUser.id,
|
||||
mentionId: mention.id,
|
||||
mentionType: mention.type,
|
||||
},
|
||||
defaults: {
|
||||
userId: document.lastModifiedById,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "documents.update": {
|
||||
const document = await Document.findByPk(event.documentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
// mentions are only created for published documents
|
||||
if (!document.publishedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mentions = DocumentHelper.parseMentions(document, {
|
||||
type: MentionType.User,
|
||||
});
|
||||
const mentionIds: string[] = [];
|
||||
|
||||
// create or find existing mention records for mentioned users
|
||||
await Promise.all(
|
||||
mentions.map(async (mention) => {
|
||||
const mentionedUser = await User.findByPk(mention.modelId);
|
||||
|
||||
if (
|
||||
!mentionedUser ||
|
||||
mentionedUser.id === document.lastModifiedById
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Mention.findOrCreate({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
mentionedUserId: mentionedUser.id,
|
||||
mentionId: mention.id,
|
||||
mentionType: mention.type,
|
||||
},
|
||||
defaults: {
|
||||
userId: document.lastModifiedById,
|
||||
},
|
||||
});
|
||||
mentionIds.push(mention.id);
|
||||
})
|
||||
);
|
||||
|
||||
// delete any mentions that no longer exist
|
||||
await Mention.destroy({
|
||||
where: {
|
||||
mentionId: {
|
||||
[Op.notIn]: mentionIds,
|
||||
},
|
||||
documentId: event.documentId,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "documents.delete": {
|
||||
await Mention.destroy({
|
||||
where: {
|
||||
documentId: event.documentId,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Node, Schema } from "prosemirror-model";
|
||||
import headingToSlug from "../editor/lib/headingToSlug";
|
||||
import textBetween from "../editor/lib/textBetween";
|
||||
import { ProsemirrorData } from "../types";
|
||||
import { getTextSerializers } from "../editor/lib/textSerializers";
|
||||
import { MentionType, ProsemirrorData, UnfurlResponse } from "../types";
|
||||
import { TextHelper } from "./TextHelper";
|
||||
import env from "../env";
|
||||
import { findChildren } from "@shared/editor/queries/findChildren";
|
||||
@@ -35,6 +36,23 @@ export type Task = {
|
||||
completed: boolean;
|
||||
};
|
||||
|
||||
export type MentionAttrs = {
|
||||
/* The type of mention */
|
||||
type: MentionType;
|
||||
/* The label/text of the mention */
|
||||
label: string;
|
||||
/* The ID of the model being mentioned */
|
||||
modelId: string;
|
||||
/* The actor who created the mention */
|
||||
actorId?: string;
|
||||
/* The unique ID of this mention instance */
|
||||
id?: string;
|
||||
/* The href for the mention */
|
||||
href?: string;
|
||||
/* Unfurl data for the mention */
|
||||
unfurl?: UnfurlResponse;
|
||||
};
|
||||
|
||||
interface User {
|
||||
name: string;
|
||||
language: string | null;
|
||||
@@ -539,4 +557,44 @@ export class ProsemirrorHelper {
|
||||
}
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse all mentions from a Prosemirror document.
|
||||
*
|
||||
* @param doc Prosemirror document node
|
||||
* @param options Optional filters to apply to mentions
|
||||
* @returns Array<MentionAttrs> of mentions found
|
||||
*/
|
||||
static parseMentions(
|
||||
doc: Node,
|
||||
options?: Partial<MentionAttrs>
|
||||
): MentionAttrs[] {
|
||||
const mentions: MentionAttrs[] = [];
|
||||
|
||||
doc.descendants((node) => {
|
||||
if (node.type.name === "mention") {
|
||||
const mentionAttrs = node.attrs as MentionAttrs;
|
||||
|
||||
// Apply filters if provided
|
||||
if (options) {
|
||||
let matches = true;
|
||||
for (const [key, value] of Object.entries(options)) {
|
||||
if (mentionAttrs[key as keyof MentionAttrs] !== value) {
|
||||
matches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matches) {
|
||||
mentions.push(mentionAttrs);
|
||||
}
|
||||
} else {
|
||||
mentions.push(mentionAttrs);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return mentions;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user