Auto-subscribe mentioned users to document (#12235)

* Auto-subscribe mentioned users to documnet

* Add tests for mention auto-subscribe and a buildMention factory

* Add tests that prior unsubscribes are respected when mentioned

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

* Batch mention subscriptions into a single transaction

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Tom Moor
2026-05-07 21:33:55 -04:00
committed by GitHub
parent 8371d709dd
commit 9c26535815
6 changed files with 487 additions and 105 deletions
+67 -20
View File
@@ -1,7 +1,7 @@
import { QueryTypes } from "sequelize";
import { QueryTypes, type Transaction } from "sequelize";
import { SubscriptionType } from "@shared/types";
import { createContext } from "@server/context";
import type { Document } from "@server/models";
import type { Document, User } from "@server/models";
import { Subscription, Event } from "@server/models";
import { sequelize } from "@server/storage/database";
import type { APIContext, DocumentEvent, RevisionEvent } from "@server/types";
@@ -105,34 +105,81 @@ export default async function subscriptionCreator({
return subscription;
}
/**
* Subscribe a single user to a document. The subscription is created if it
* does not exist; an existing subscription that has been deleted is left as-is
* so that the user's prior unsubscribe is respected.
*
* @param user The user to subscribe.
* @param document The document to subscribe the user to.
* @param event The event that triggered the subscription creation.
* @param options.transaction An existing transaction to run within. When
* subscribing many users in a row, callers should open a single transaction
* and pass it in to avoid the overhead of one BEGIN/COMMIT per call.
*/
export const subscribeUserToDocument = async (
user: User,
document: Document,
event: DocumentEvent | RevisionEvent,
options: { transaction?: Transaction } = {}
): Promise<void> => {
const run = (transaction: Transaction) =>
subscriptionCreator({
ctx: createContext({
user,
authType: event.authType,
ip: event.ip,
transaction,
}),
documentId: document.id,
event: SubscriptionType.Document,
resubscribe: false,
});
if (options.transaction) {
await run(options.transaction);
return;
}
await sequelize.transaction(run);
};
/**
* Subscribe a batch of users to a document inside a single transaction.
*
* @param users The users to subscribe.
* @param document The document to subscribe the users to.
* @param event The event that triggered the subscription creation.
*/
export const subscribeUsersToDocument = async (
users: User[],
document: Document,
event: DocumentEvent | RevisionEvent
): Promise<void> => {
if (!users.length) {
return;
}
await sequelize.transaction(async (transaction) => {
for (const user of users) {
await subscribeUserToDocument(user, document, event, { transaction });
}
});
};
/**
* Create any new subscriptions that might be missing for collaborators in the
* document on publish and revision creation. This does mean that there is a
* short period of time where the user is not subscribed after editing until a
* revision is created.
*
* @param document The document to create subscriptions for
* @param event The event that triggered the subscription creation
* @param document The document to create subscriptions for.
* @param event The event that triggered the subscription creation.
*/
export const createSubscriptionsForDocument = async (
document: Document,
event: DocumentEvent | RevisionEvent
): Promise<void> => {
const users = await document.collaborators();
for (const user of users) {
await sequelize.transaction(async (transaction) => {
await subscriptionCreator({
ctx: createContext({
user,
authType: event.authType,
ip: event.ip,
transaction,
}),
documentId: document.id,
event: SubscriptionType.Document,
resubscribe: false,
});
});
}
await subscribeUsersToDocument(users, document, event);
};
@@ -1,11 +1,16 @@
import { v4 as uuidv4 } from "uuid";
import { MentionType, NotificationEventType } from "@shared/types";
import { Notification } from "@server/models";
import {
MentionType,
NotificationEventType,
SubscriptionType,
} from "@shared/types";
import { Notification, Subscription } from "@server/models";
import {
buildDocument,
buildCollection,
buildGroup,
buildGroupUser,
buildMention,
buildProseMirrorDoc,
buildUser,
} from "@server/test/factories";
import DocumentPublishedNotificationsTask from "./DocumentPublishedNotificationsTask";
@@ -141,26 +146,19 @@ describe("documents.publish", () => {
const document = await buildDocument({
teamId: actor.teamId,
userId: actor.id,
content: {
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "mention",
attrs: {
id: uuidv4(),
type: MentionType.Group,
label: group.name,
modelId: group.id,
actorId: actor.id,
},
},
],
},
],
},
content: buildProseMirrorDoc([
{
type: "paragraph",
content: [
buildMention({
type: MentionType.Group,
modelId: group.id,
actorId: actor.id,
label: group.name,
}),
],
},
]).toJSON(),
});
const processor = new DocumentPublishedNotificationsTask();
@@ -175,4 +173,139 @@ describe("documents.publish", () => {
expect(spy).not.toHaveBeenCalled();
});
it("should subscribe a mentioned user to the document", async () => {
const actor = await buildUser();
const mentioned = await buildUser({ teamId: actor.teamId });
const document = await buildDocument({
teamId: actor.teamId,
userId: actor.id,
content: buildProseMirrorDoc([
{
type: "paragraph",
content: [buildMention({ modelId: mentioned.id, actorId: actor.id })],
},
]).toJSON(),
});
const processor = new DocumentPublishedNotificationsTask();
await processor.perform({
name: "documents.publish",
documentId: document.id,
collectionId: document.collectionId!,
teamId: document.teamId,
actorId: actor.id,
ip,
});
const subscription = await Subscription.findOne({
where: {
userId: mentioned.id,
documentId: document.id,
event: SubscriptionType.Document,
},
});
expect(subscription).not.toBeNull();
});
it("should respect a prior unsubscribe when a user is mentioned", async () => {
const actor = await buildUser();
const mentioned = await buildUser({ teamId: actor.teamId });
const document = await buildDocument({
teamId: actor.teamId,
userId: actor.id,
content: buildProseMirrorDoc([
{
type: "paragraph",
content: [buildMention({ modelId: mentioned.id, actorId: actor.id })],
},
]).toJSON(),
});
// The mentioned user previously subscribed and then unsubscribed.
const prior = await Subscription.create({
userId: mentioned.id,
documentId: document.id,
event: SubscriptionType.Document,
});
await prior.destroy();
const processor = new DocumentPublishedNotificationsTask();
await processor.perform({
name: "documents.publish",
documentId: document.id,
collectionId: document.collectionId!,
teamId: document.teamId,
actorId: actor.id,
ip,
});
// No active subscription should exist.
const active = await Subscription.findOne({
where: {
userId: mentioned.id,
documentId: document.id,
event: SubscriptionType.Document,
},
});
expect(active).toBeNull();
// The original soft-deleted subscription should still be soft-deleted.
const withDeleted = await Subscription.findOne({
where: {
userId: mentioned.id,
documentId: document.id,
event: SubscriptionType.Document,
},
paranoid: false,
});
expect(withDeleted).not.toBeNull();
expect(withDeleted?.deletedAt).not.toBeNull();
});
it("should not subscribe users mentioned via a group", async () => {
const actor = await buildUser();
const group = await buildGroup({ teamId: actor.teamId });
const member = await buildUser({ teamId: actor.teamId });
await buildGroupUser({ groupId: group.id, userId: member.id });
const document = await buildDocument({
teamId: actor.teamId,
userId: actor.id,
content: buildProseMirrorDoc([
{
type: "paragraph",
content: [
buildMention({
type: MentionType.Group,
modelId: group.id,
actorId: actor.id,
label: group.name,
}),
],
},
]).toJSON(),
});
const processor = new DocumentPublishedNotificationsTask();
await processor.perform({
name: "documents.publish",
documentId: document.id,
collectionId: document.collectionId!,
teamId: document.teamId,
actorId: actor.id,
ip,
});
const subscription = await Subscription.findOne({
where: {
userId: member.id,
documentId: document.id,
event: SubscriptionType.Document,
},
});
expect(subscription).toBeNull();
});
});
@@ -1,5 +1,8 @@
import { MentionType, NotificationEventType } from "@shared/types";
import { createSubscriptionsForDocument } from "@server/commands/subscriptionCreator";
import {
createSubscriptionsForDocument,
subscribeUsersToDocument,
} from "@server/commands/subscriptionCreator";
import { Document, Group, Notification, User, GroupUser } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import NotificationHelper from "@server/models/helpers/NotificationHelper";
@@ -22,22 +25,32 @@ export default class DocumentPublishedNotificationsTask extends BaseTask<Documen
const mentions = DocumentHelper.parseMentions(document, {
type: MentionType.User,
});
const userIdsProcessed = new Set<string>();
const userIdsMentioned: string[] = [];
const usersToSubscribe: User[] = [];
for (const mention of mentions) {
if (userIdsMentioned.includes(mention.modelId)) {
if (userIdsProcessed.has(mention.modelId)) {
continue;
}
userIdsProcessed.add(mention.modelId);
const recipient = await User.findByPk(mention.modelId);
if (
recipient &&
recipient.id !== mention.actorId &&
!recipient ||
recipient.id === mention.actorId ||
!(await canUserAccessDocument(recipient, document.id))
) {
continue;
}
usersToSubscribe.push(recipient);
if (
recipient.subscribedToEventType(
NotificationEventType.MentionedInDocument
) &&
(await canUserAccessDocument(recipient, document.id))
)
) {
await Notification.create({
event: NotificationEventType.MentionedInDocument,
@@ -50,6 +63,8 @@ export default class DocumentPublishedNotificationsTask extends BaseTask<Documen
}
}
await subscribeUsersToDocument(usersToSubscribe, document, event);
// send notifications to users in mentioned groups
const groupMentions = DocumentHelper.parseMentions(document, {
type: MentionType.Group,
@@ -1,7 +1,8 @@
import type { DeepPartial } from "utility-types";
import type { ProsemirrorData } from "@shared/types";
import { v4 as uuidv4 } from "uuid";
import { MentionType, NotificationEventType } from "@shared/types";
import {
MentionType,
NotificationEventType,
SubscriptionType,
} from "@shared/types";
import { createContext } from "@server/context";
import { parser } from "@server/editor";
import type { Document } from "@server/models";
@@ -16,6 +17,8 @@ import {
buildDocument,
buildGroup,
buildGroupUser,
buildMention,
buildProseMirrorDoc,
buildUser,
} from "@server/test/factories";
import RevisionCreatedNotificationsTask from "./RevisionCreatedNotificationsTask";
@@ -539,28 +542,20 @@ describe("revisions.create", () => {
// Now add a mention the only change is the mention node itself, which
// renders as "@<label>" in plain text and may be below the 5-char
// threshold that gates generic update notifications.
const mentionContent: DeepPartial<ProsemirrorData> = {
type: "doc",
content: [
...(document.content?.content ?? []),
{
type: "paragraph",
content: [
{
type: "mention",
attrs: {
type: MentionType.User,
label: mentioned.name,
modelId: mentioned.id,
actorId: actor.id,
id: "test-mention-id",
},
},
],
},
],
};
document.content = mentionContent as ProsemirrorData;
document.content = buildProseMirrorDoc([
...(document.content?.content ?? []),
{
type: "paragraph",
content: [
buildMention({
id: "test-mention-id",
modelId: mentioned.id,
actorId: actor.id,
label: mentioned.name,
}),
],
},
]).toJSON();
document.updatedAt = new Date();
await document.save();
@@ -611,30 +606,23 @@ describe("revisions.create", () => {
await Revision.createFromDocument(createContext({ user: actor }), document);
// Update document to include a group mention
document.content = {
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "Updated content with a group mention ",
},
{
type: "mention",
attrs: {
id: uuidv4(),
type: MentionType.Group,
label: group.name,
modelId: group.id,
actorId: actor.id,
},
},
],
},
],
};
document.content = buildProseMirrorDoc([
{
type: "paragraph",
content: [
{
type: "text",
text: "Updated content with a group mention ",
},
buildMention({
type: MentionType.Group,
modelId: group.id,
actorId: actor.id,
label: group.name,
}),
],
},
]).toJSON();
document.updatedAt = new Date();
await document.save();
@@ -655,4 +643,168 @@ describe("revisions.create", () => {
expect(spy).not.toHaveBeenCalled();
});
it("should subscribe a mentioned user to the document", async () => {
const actor = await buildUser();
const mentioned = await buildUser({ teamId: actor.teamId, name: "Kim" });
const document = await buildDocument({
teamId: actor.teamId,
userId: actor.id,
});
await Revision.createFromDocument(createContext({ user: actor }), document);
document.content = buildProseMirrorDoc([
...(document.content?.content ?? []),
{
type: "paragraph",
content: [buildMention({ modelId: mentioned.id, actorId: actor.id })],
},
]).toJSON();
document.updatedAt = new Date();
await document.save();
const revision = await Revision.createFromDocument(
createContext({ user: actor }),
document
);
const task = new RevisionCreatedNotificationsTask();
await task.perform({
name: "revisions.create",
documentId: document.id,
teamId: document.teamId,
actorId: actor.id,
modelId: revision.id,
ip,
});
const subscription = await Subscription.findOne({
where: {
userId: mentioned.id,
documentId: document.id,
event: SubscriptionType.Document,
},
});
expect(subscription).not.toBeNull();
});
it("should respect a prior unsubscribe when a user is mentioned", async () => {
const actor = await buildUser();
const mentioned = await buildUser({ teamId: actor.teamId });
const document = await buildDocument({
teamId: actor.teamId,
userId: actor.id,
});
await Revision.createFromDocument(createContext({ user: actor }), document);
// The mentioned user previously subscribed and then unsubscribed.
const prior = await Subscription.create({
userId: mentioned.id,
documentId: document.id,
event: SubscriptionType.Document,
});
await prior.destroy();
document.content = buildProseMirrorDoc([
...(document.content?.content ?? []),
{
type: "paragraph",
content: [buildMention({ modelId: mentioned.id, actorId: actor.id })],
},
]).toJSON();
document.updatedAt = new Date();
await document.save();
const revision = await Revision.createFromDocument(
createContext({ user: actor }),
document
);
const task = new RevisionCreatedNotificationsTask();
await task.perform({
name: "revisions.create",
documentId: document.id,
teamId: document.teamId,
actorId: actor.id,
modelId: revision.id,
ip,
});
// No active subscription should exist.
const active = await Subscription.findOne({
where: {
userId: mentioned.id,
documentId: document.id,
event: SubscriptionType.Document,
},
});
expect(active).toBeNull();
// The original soft-deleted subscription should still be soft-deleted.
const withDeleted = await Subscription.findOne({
where: {
userId: mentioned.id,
documentId: document.id,
event: SubscriptionType.Document,
},
paranoid: false,
});
expect(withDeleted).not.toBeNull();
expect(withDeleted?.deletedAt).not.toBeNull();
});
it("should not subscribe users mentioned via a group", async () => {
const actor = await buildUser();
const group = await buildGroup({ teamId: actor.teamId });
const member = await buildUser({ teamId: actor.teamId });
await buildGroupUser({ groupId: group.id, userId: member.id });
const document = await buildDocument({
teamId: actor.teamId,
userId: actor.id,
});
await Revision.createFromDocument(createContext({ user: actor }), document);
document.content = buildProseMirrorDoc([
{
type: "paragraph",
content: [
buildMention({
type: MentionType.Group,
modelId: group.id,
actorId: actor.id,
label: group.name,
}),
],
},
]).toJSON();
document.updatedAt = new Date();
await document.save();
const revision = await Revision.createFromDocument(
createContext({ user: actor }),
document
);
const task = new RevisionCreatedNotificationsTask();
await task.perform({
name: "revisions.create",
documentId: document.id,
teamId: document.teamId,
actorId: actor.id,
modelId: revision.id,
ip,
});
const subscription = await Subscription.findOne({
where: {
userId: member.id,
documentId: document.id,
event: SubscriptionType.Document,
},
});
expect(subscription).toBeNull();
});
});
@@ -2,7 +2,10 @@ import { subHours } from "date-fns";
import { differenceBy } from "es-toolkit/compat";
import { Op } from "sequelize";
import { MentionType, NotificationEventType } from "@shared/types";
import { createSubscriptionsForDocument } from "@server/commands/subscriptionCreator";
import {
createSubscriptionsForDocument,
subscribeUsersToDocument,
} from "@server/commands/subscriptionCreator";
import env from "@server/env";
import Logger from "@server/logging/Logger";
import {
@@ -47,21 +50,31 @@ export default class RevisionCreatedNotificationsTask extends BaseTask<RevisionE
];
const mentions = differenceBy(newMentions, oldMentions, "id");
const userIdsProcessed = new Set<string>();
const userIdsMentioned: string[] = [];
const usersToSubscribe: User[] = [];
for (const mention of mentions) {
if (userIdsMentioned.includes(mention.modelId)) {
if (userIdsProcessed.has(mention.modelId)) {
continue;
}
userIdsProcessed.add(mention.modelId);
const recipient = await User.findByPk(mention.modelId);
if (
recipient &&
recipient.id !== mention.actorId &&
!recipient ||
recipient.id === mention.actorId ||
!(await canUserAccessDocument(recipient, document.id))
) {
continue;
}
usersToSubscribe.push(recipient);
if (
recipient.subscribedToEventType(
NotificationEventType.MentionedInDocument
) &&
(await canUserAccessDocument(recipient, document.id))
)
) {
await Notification.create({
event: NotificationEventType.MentionedInDocument,
@@ -76,6 +89,8 @@ export default class RevisionCreatedNotificationsTask extends BaseTask<RevisionE
}
}
await subscribeUsersToDocument(usersToSubscribe, document, event);
// Send notifications to users in mentioned groups
const oldGroupMentions = before
? DocumentHelper.parseMentions(before, { type: MentionType.Group })
+20
View File
@@ -13,6 +13,7 @@ import {
ImportState,
IntegrationService,
IntegrationType,
MentionType,
NotificationEventType,
SubscriptionType,
UserRole,
@@ -910,6 +911,25 @@ export function buildProseMirrorDoc(content: DeepPartial<ProsemirrorData>[]) {
});
}
export function buildMention(overrides: {
type?: MentionType;
modelId: string;
actorId: string;
label?: string;
id?: string;
}): DeepPartial<ProsemirrorData> {
return {
type: "mention",
attrs: {
id: overrides.id ?? randomUUID(),
type: overrides.type ?? MentionType.User,
label: overrides.label ?? faker.name.fullName(),
modelId: overrides.modelId,
actorId: overrides.actorId,
},
};
}
export function buildCommentMark(overrides: {
id?: string;
userId?: string;