Compare commits

...

4 Commits

Author SHA1 Message Date
Tom Moor 6863fe3698 Merge branch 'main' into codegen-bot/implement-mention-model-tracking-9268 2025-12-12 19:47:16 -05:00
Copilot b68997f78a Fix MentionsProcessor tests: implement parseMentions and resolve circular dependency (#10866)
* Initial plan

* Fix circular dependency in Mention model by using lazy import for can function

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Implement parseMentions method and fix test markdown format for mentions

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2025-12-12 07:20:41 -05:00
codegen-sh[bot] ac2d3bf3cb Fix TypeScript compilation errors in Mention model
- Replace authorize() with can() function for proper boolean return
- Add missing createdAt property to documents.update events in tests
- Add non-null assertions for required properties
- Fix event type definitions to match DocumentEvent interface
2025-05-31 00:17:15 +00:00
codegen-sh[bot] 94a8326c68 Implement Mention model for tracking user mentions
- Add Mention model with relationships to User and Document
- Create database migration for mentions table
- Add MentionsProcessor to handle document events
- Add comprehensive tests for MentionsProcessor
- Update model relationships in Document and User models

Addresses issue #9268 for tracking user mentions similar to backlinks
2025-05-30 23:53:38 +00:00
9 changed files with 583 additions and 1 deletions
+4
View File
@@ -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");
},
};
+4
View File
@@ -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[];
+104
View File
@@ -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;
+4
View File
@@ -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 {
+2
View File
@@ -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:
}
}
}
+59 -1
View File
@@ -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;
}
}