Skip to content

Commit b62cb53

Browse files
authored
Fix @mention notifications in activity comments (#219)
1 parent 4de3629 commit b62cb53

5 files changed

Lines changed: 233 additions & 3 deletions

File tree

apps/web/app/challenges/[id]/(dashboard)/notifications/notifications-list.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ function getNotificationIcon(type: string) {
3838
case "comment_like":
3939
return <Heart className="h-4 w-4 text-pink-500" />;
4040
case "comment":
41+
case "comment_mention":
4142
return <MessageCircle className="h-4 w-4 text-blue-500" />;
4243
case "follow":
4344
case "new_follower":
@@ -83,6 +84,8 @@ export function getNotificationMessage(notification: Notification) {
8384
return `${actorName} liked your comment`;
8485
case "comment":
8586
return `${actorName} commented on your activity`;
87+
case "comment_mention":
88+
return `${actorName} mentioned you in a comment`;
8689
case "mention":
8790
return `${actorName} mentioned you`;
8891
case "forum_mention":
@@ -186,7 +189,7 @@ export function getNotificationLink(notification: Notification, challengeId: str
186189
const cId = notification.data?.challengeId ?? challengeId;
187190
return `/challenges/${cId}/users/${notification.actor.id}`;
188191
}
189-
if (notification.type === "comment_like" && notification.data?.activityId) {
192+
if ((notification.type === "comment_like" || notification.type === "comment_mention") && notification.data?.activityId) {
190193
const commentId = notification.data.commentId as string | undefined;
191194
const base = `/challenges/${challengeId}/activities/${notification.data.activityId}`;
192195
return commentId ? `${base}?commentId=${commentId}` : base;

apps/web/tests/api/comments.test.ts

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect, beforeEach } from "vitest";
1+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
22
import { api, internal } from "@repo/backend";
33
import type { Id } from "@repo/backend/_generated/dataModel";
44
import {
@@ -803,6 +803,136 @@ describe("Comments", () => {
803803
});
804804
});
805805

806+
// ── Comment Mention Notifications ───────────────────────────
807+
808+
describe("comment mention notifications", () => {
809+
beforeEach(() => {
810+
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
811+
});
812+
813+
afterEach(async () => {
814+
await t.finishAllScheduledFunctions(vi.runAllTimers);
815+
vi.useRealTimers();
816+
});
817+
818+
function tiptapMention(userId: string): string {
819+
return JSON.stringify({
820+
type: "doc",
821+
content: [
822+
{
823+
type: "paragraph",
824+
content: [
825+
{ type: "text", text: "Hey " },
826+
{ type: "mention", attrs: { id: userId, label: "someone" } },
827+
{ type: "text", text: " nice work!" },
828+
],
829+
},
830+
],
831+
});
832+
}
833+
834+
it("sends comment_mention notification to mentioned user", async () => {
835+
const { ownerId, participantId, activityId } =
836+
await setupChallengeWithActivity();
837+
838+
// Create a third user to be mentioned
839+
const mentionedId = await createTestUser(t, {
840+
email: "mentioned@example.com",
841+
username: "mentioned",
842+
});
843+
const challengeId = (
844+
await t.run(async (ctx) => ctx.db.get(activityId))
845+
)!.challengeId;
846+
await createTestParticipation(t, mentionedId, challengeId);
847+
848+
const tOwnerAuth = t.withIdentity({
849+
subject: "owner-sub",
850+
email: "owner@example.com",
851+
});
852+
853+
await tOwnerAuth.mutation(api.mutations.comments.create, {
854+
activityId: activityId as Id<"activities">,
855+
content: tiptapMention(mentionedId as string),
856+
});
857+
858+
// Run the scheduled sendCommentMentionNotifications
859+
await t.finishAllScheduledFunctions(vi.runAllTimers);
860+
861+
const notifications = await t.run(async (ctx) => {
862+
return ctx.db
863+
.query("notifications")
864+
.withIndex("userId", (q) => q.eq("userId", mentionedId))
865+
.collect();
866+
});
867+
868+
expect(notifications.some((n) => n.type === "comment_mention")).toBe(
869+
true,
870+
);
871+
const mentionNotif = notifications.find(
872+
(n) => n.type === "comment_mention",
873+
)!;
874+
expect(mentionNotif.actorId).toBe(ownerId);
875+
expect(mentionNotif.data.activityId).toBe(activityId);
876+
});
877+
878+
it("does NOT send comment_mention for self-mentions", async () => {
879+
const { ownerId, activityId } = await setupChallengeWithActivity();
880+
881+
const tOwnerAuth = t.withIdentity({
882+
subject: "owner-sub",
883+
email: "owner@example.com",
884+
});
885+
886+
// Owner mentions themselves
887+
await tOwnerAuth.mutation(api.mutations.comments.create, {
888+
activityId: activityId as Id<"activities">,
889+
content: tiptapMention(ownerId as string),
890+
});
891+
892+
await t.finishAllScheduledFunctions(vi.runAllTimers);
893+
894+
const notifications = await t.run(async (ctx) => {
895+
return ctx.db
896+
.query("notifications")
897+
.withIndex("userId", (q) => q.eq("userId", ownerId))
898+
.collect();
899+
});
900+
901+
expect(notifications.some((n) => n.type === "comment_mention")).toBe(
902+
false,
903+
);
904+
});
905+
906+
it("does NOT send comment_mention for plain text comments", async () => {
907+
const { participantId, activityId } =
908+
await setupChallengeWithActivity();
909+
910+
const tOwnerAuth = t.withIdentity({
911+
subject: "owner-sub",
912+
email: "owner@example.com",
913+
});
914+
915+
await tOwnerAuth.mutation(api.mutations.comments.create, {
916+
activityId: activityId as Id<"activities">,
917+
content: "Just a plain text comment",
918+
});
919+
920+
await t.finishAllScheduledFunctions(vi.runAllTimers);
921+
922+
const notifications = await t.run(async (ctx) => {
923+
return ctx.db
924+
.query("notifications")
925+
.withIndex("userId", (q) => q.eq("userId", participantId))
926+
.collect();
927+
});
928+
929+
// Should only have the "comment" notification to the activity owner, no mention
930+
expect(notifications.some((n) => n.type === "comment_mention")).toBe(
931+
false,
932+
);
933+
});
934+
});
935+
806936
// ── Feed Score Guard ─────────────────────────────────────────
807937

808938
describe("feed score guard", () => {

apps/web/tests/lib/notification-utils.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ describe("getNotificationMessage", () => {
4040
expect(getNotificationMessage(n)).toBe("Alice commented on your activity");
4141
});
4242

43+
it("returns correct message for comment_mention", () => {
44+
const n = makeNotification({ type: "comment_mention" });
45+
expect(getNotificationMessage(n)).toBe("Alice mentioned you in a comment");
46+
});
47+
4348
it("returns correct message for new_follower", () => {
4449
const n = makeNotification({ type: "new_follower" });
4550
expect(getNotificationMessage(n)).toBe("Alice started following you");
@@ -185,6 +190,26 @@ describe("getNotificationLink", () => {
185190
);
186191
});
187192

193+
it("routes comment_mention to activity page with commentId query param", () => {
194+
const n = makeNotification({
195+
type: "comment_mention",
196+
data: { activityId: "act-1", commentId: "comm-1" },
197+
});
198+
expect(getNotificationLink(n, challengeId)).toBe(
199+
"/challenges/challenge-1/activities/act-1?commentId=comm-1",
200+
);
201+
});
202+
203+
it("routes comment_mention to activity page without commentId when missing", () => {
204+
const n = makeNotification({
205+
type: "comment_mention",
206+
data: { activityId: "act-1" },
207+
});
208+
expect(getNotificationLink(n, challengeId)).toBe(
209+
"/challenges/challenge-1/activities/act-1",
210+
);
211+
});
212+
188213
it("routes like with activityId to activity page", () => {
189214
const n = makeNotification({
190215
type: "like",

packages/backend/mutations/comments.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import { mutation } from "../_generated/server";
1+
import { internalMutation, mutation } from "../_generated/server";
22
import { v } from "convex/values";
33
import type { Id } from "../_generated/dataModel";
44
import type { MutationCtx } from "../_generated/server";
5+
import { internal } from "../_generated/api";
56
import { requireCurrentUser } from "../lib/ids";
7+
import { extractMentionedUserIds } from "../lib/mentions";
68
import { insertNotification } from "../lib/notifications";
79
import { recomputeFeedScore } from "../lib/feedScore";
810

@@ -67,13 +69,62 @@ export const create = mutation({
6769
});
6870
}
6971

72+
// Send mention notifications async
73+
const mentionedUserIds = extractMentionedUserIds(args.content);
74+
if (mentionedUserIds.length > 0) {
75+
await ctx.scheduler.runAfter(
76+
0,
77+
internal.mutations.comments.sendCommentMentionNotifications,
78+
{
79+
commentId,
80+
activityId: args.activityId,
81+
actorId: user._id,
82+
mentionedUserIds,
83+
},
84+
);
85+
}
86+
7087
// Recompute feed score after new comment
7188
await recomputeFeedScore(ctx, args.activityId);
7289

7390
return commentId;
7491
},
7592
});
7693

94+
/**
95+
* Internal: send mention notifications for a comment on an activity.
96+
*/
97+
export const sendCommentMentionNotifications = internalMutation({
98+
args: {
99+
commentId: v.id("comments"),
100+
activityId: v.id("activities"),
101+
actorId: v.id("users"),
102+
mentionedUserIds: v.array(v.string()),
103+
},
104+
handler: async (ctx, args) => {
105+
const now = Date.now();
106+
for (const userId of args.mentionedUserIds) {
107+
// Skip self-mentions
108+
if (userId === args.actorId) continue;
109+
110+
// Verify the user exists
111+
const user = await ctx.db.get(userId as Id<"users">);
112+
if (!user) continue;
113+
114+
await ctx.db.insert("notifications", {
115+
userId: userId as Id<"users">,
116+
actorId: args.actorId,
117+
type: "comment_mention",
118+
data: {
119+
activityId: args.activityId,
120+
commentId: args.commentId,
121+
},
122+
createdAt: now,
123+
});
124+
}
125+
},
126+
});
127+
77128
/**
78129
* Create a comment on a feedback item. Reporter and admins can comment.
79130
*/
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Fix Comment Tag Notifications
2+
3+
**Date:** 2026-03-13
4+
**Description:** @mentions in activity comments were not triggering notifications in the notification feed.
5+
6+
## Root Cause
7+
8+
The `comments.create` mutation notified the activity owner but did not extract `@mention` nodes from the Tiptap JSON content. The forum posts module already had this working via `extractMentionedUserIds` + a scheduled internal mutation, but comments never implemented the same pattern.
9+
10+
## Changes
11+
12+
- [x] Import `extractMentionedUserIds` and `internal` in `packages/backend/mutations/comments.ts`
13+
- [x] After creating a comment, extract mentioned user IDs from Tiptap content and schedule `sendCommentMentionNotifications`
14+
- [x] Add `sendCommentMentionNotifications` internal mutation (skips self-mentions and non-existent users)
15+
- [x] Add `comment_mention` notification type to frontend icon map, message generator, and link handler
16+
- [x] Deep-link comment mention notifications to the specific comment on the activity page
17+
18+
## Files Modified
19+
20+
- `packages/backend/mutations/comments.ts` — mention extraction + internal mutation
21+
- `apps/web/app/challenges/[id]/(dashboard)/notifications/notifications-list.tsx` — UI for `comment_mention` type

0 commit comments

Comments
 (0)