diff --git a/apps/docs-new/content/docs/users/delete.mdx b/apps/docs-new/content/docs/users/delete.mdx
index 078ba552a..4418cb77a 100644
--- a/apps/docs-new/content/docs/users/delete.mdx
+++ b/apps/docs-new/content/docs/users/delete.mdx
@@ -44,9 +44,7 @@ The following business entities are transferred to the admin performing the dele
### Communities
-- **Community Ownership**: All communities created by the user
-- **Community Posts**: All posts created by the user in any community
-- **Community Comments**: All comments made by the user
+- **Community Ownership**: All communities created by the user (moderator role and related pages are transferred)
### Email Marketing
@@ -81,6 +79,8 @@ The following personal data is permanently removed:
### Community Participation
- Community membership records
+- Community posts and comments created by the user
+- Community reactions (emoji on posts, comments, and replies) by that user, and on posts they authored; soft-deleted content may retain reactions until hard or user delete
- Community post subscriptions
- Community reports filed by the user
diff --git a/apps/web/.migrations/20-06-26_00-00-convert-likes-to-reactions.js b/apps/web/.migrations/20-06-26_00-00-convert-likes-to-reactions.js
new file mode 100644
index 000000000..995990f91
--- /dev/null
+++ b/apps/web/.migrations/20-06-26_00-00-convert-likes-to-reactions.js
@@ -0,0 +1,290 @@
+/**
+ * Migrates legacy community `likes: string[]` into the
+ * `communityreactions` collection as heart (❤️) rows.
+ *
+ * Sources:
+ * - CommunityPost.likes
+ * - CommunityComment.likes
+ * - CommunityComment.replies[].likes
+ *
+ * After inserting reaction rows, unsets legacy likes fields.
+ * Idempotent: unique index (domain, entityType, entityId, userId, emoji)
+ * via $setOnInsert upserts.
+ *
+ * Usage:
+ * DB_CONNECTION_STRING= node 20-06-26_00-00-convert-likes-to-reactions.js
+ */
+import mongoose from "mongoose";
+
+const DB_CONNECTION_STRING = process.env.DB_CONNECTION_STRING;
+
+if (!DB_CONNECTION_STRING) {
+ throw new Error("DB_CONNECTION_STRING is not set");
+}
+
+const BATCH_SIZE = 500;
+const HEART_EMOJI = "❤️";
+
+/** Mirrors Constants.CommunityReactionEntityType */
+const CommunityReactionEntityType = {
+ POST: "post",
+ COMMENT: "comment",
+ REPLY: "reply",
+};
+
+function heartOpsFromLikes({
+ domain,
+ communityId,
+ entityType,
+ entityId,
+ postId,
+ commentId,
+ likes,
+}) {
+ const ops = [];
+ const userIds = Array.isArray(likes) ? likes : [];
+ for (const userId of userIds) {
+ if (!userId) continue;
+ const doc = {
+ domain,
+ communityId,
+ entityType,
+ entityId,
+ postId,
+ emoji: HEART_EMOJI,
+ userId,
+ };
+ if (commentId) {
+ doc.commentId = commentId;
+ }
+ ops.push({
+ updateOne: {
+ filter: {
+ domain,
+ entityType,
+ entityId,
+ userId,
+ emoji: HEART_EMOJI,
+ },
+ update: { $setOnInsert: doc },
+ upsert: true,
+ },
+ });
+ }
+ return ops;
+}
+
+async function flushOps(collection, ops, label, counter) {
+ if (ops.length === 0) return counter;
+ const result = await collection.bulkWrite(ops, { ordered: false });
+ const n =
+ (result.upsertedCount || 0) +
+ (result.modifiedCount || 0) +
+ (result.insertedCount || 0);
+ counter += n;
+ console.log(` ${label}: wrote ~${counter} reaction ops...`);
+ return counter;
+}
+
+(async () => {
+ try {
+ await mongoose.connect(DB_CONNECTION_STRING);
+ const db = mongoose.connection.db;
+ if (!db) throw new Error("Could not connect to database");
+
+ const reactionsCol = db.collection("communityreactions");
+ await reactionsCol.createIndex(
+ {
+ domain: 1,
+ entityType: 1,
+ entityId: 1,
+ userId: 1,
+ emoji: 1,
+ },
+ { unique: true },
+ );
+ await reactionsCol.createIndex({
+ domain: 1,
+ entityType: 1,
+ entityId: 1,
+ });
+ await reactionsCol.createIndex({ domain: 1, postId: 1 });
+ await reactionsCol.createIndex({ domain: 1, userId: 1 });
+
+ // --- Posts ---
+ console.log("Migrating CommunityPost likes...");
+ const postCursor = db
+ .collection("communityposts")
+ .find({ likes: { $exists: true } })
+ .batchSize(BATCH_SIZE);
+
+ let postReactionOps = [];
+ let postUnsetOps = [];
+ let postReactionCount = 0;
+ let postUnsetCount = 0;
+
+ while (await postCursor.hasNext()) {
+ const doc = await postCursor.next();
+ if (!doc) continue;
+
+ postReactionOps.push(
+ ...heartOpsFromLikes({
+ domain: doc.domain,
+ communityId: doc.communityId,
+ entityType: CommunityReactionEntityType.POST,
+ entityId: doc.postId,
+ postId: doc.postId,
+ likes: doc.likes,
+ }),
+ );
+
+ postUnsetOps.push({
+ updateOne: {
+ filter: { _id: doc._id },
+ update: { $unset: { likes: "" } },
+ },
+ });
+
+ if (postReactionOps.length >= BATCH_SIZE) {
+ postReactionCount = await flushOps(
+ reactionsCol,
+ postReactionOps,
+ "posts",
+ postReactionCount,
+ );
+ postReactionOps = [];
+ }
+ if (postUnsetOps.length >= BATCH_SIZE) {
+ const r = await db
+ .collection("communityposts")
+ .bulkWrite(postUnsetOps);
+ postUnsetCount += r.modifiedCount || 0;
+ postUnsetOps = [];
+ }
+ }
+ postReactionCount = await flushOps(
+ reactionsCol,
+ postReactionOps,
+ "posts",
+ postReactionCount,
+ );
+ if (postUnsetOps.length > 0) {
+ const r = await db
+ .collection("communityposts")
+ .bulkWrite(postUnsetOps);
+ postUnsetCount += r.modifiedCount || 0;
+ }
+ console.log(
+ `✅ Posts: reaction upserts ~${postReactionCount}, cleaned ${postUnsetCount} docs.`,
+ );
+
+ // --- Comments + replies ---
+ console.log("Migrating CommunityComment likes...");
+ const commentCursor = db
+ .collection("communitycomments")
+ .find({
+ $or: [
+ { likes: { $exists: true } },
+ { "replies.likes": { $exists: true } },
+ ],
+ })
+ .batchSize(BATCH_SIZE);
+
+ let commentReactionOps = [];
+ let commentUnsetOps = [];
+ let commentReactionCount = 0;
+ let commentUnsetCount = 0;
+
+ while (await commentCursor.hasNext()) {
+ const doc = await commentCursor.next();
+ if (!doc) continue;
+
+ commentReactionOps.push(
+ ...heartOpsFromLikes({
+ domain: doc.domain,
+ communityId: doc.communityId,
+ entityType: CommunityReactionEntityType.COMMENT,
+ entityId: doc.commentId,
+ postId: doc.postId,
+ likes: doc.likes,
+ }),
+ );
+
+ const cleanedReplies = Array.isArray(doc.replies)
+ ? doc.replies.map((reply) => {
+ commentReactionOps.push(
+ ...heartOpsFromLikes({
+ domain: doc.domain,
+ communityId: doc.communityId,
+ entityType: CommunityReactionEntityType.REPLY,
+ entityId: reply.replyId,
+ postId: doc.postId,
+ commentId: doc.commentId,
+ likes: reply.likes,
+ }),
+ );
+ const { likes, ...rest } = reply;
+ return rest;
+ })
+ : [];
+
+ commentUnsetOps.push({
+ updateOne: {
+ filter: { _id: doc._id },
+ update: {
+ $set: { replies: cleanedReplies },
+ $unset: { likes: "" },
+ },
+ },
+ });
+
+ if (commentReactionOps.length >= BATCH_SIZE) {
+ commentReactionCount = await flushOps(
+ reactionsCol,
+ commentReactionOps,
+ "comments",
+ commentReactionCount,
+ );
+ commentReactionOps = [];
+ }
+ if (commentUnsetOps.length >= BATCH_SIZE) {
+ const r = await db
+ .collection("communitycomments")
+ .bulkWrite(commentUnsetOps);
+ commentUnsetCount += r.modifiedCount || 0;
+ commentUnsetOps = [];
+ }
+ }
+ commentReactionCount = await flushOps(
+ reactionsCol,
+ commentReactionOps,
+ "comments",
+ commentReactionCount,
+ );
+ if (commentUnsetOps.length > 0) {
+ const r = await db
+ .collection("communitycomments")
+ .bulkWrite(commentUnsetOps);
+ commentUnsetCount += r.modifiedCount || 0;
+ }
+ console.log(
+ `✅ Comments: reaction upserts ~${commentReactionCount}, cleaned ${commentUnsetCount} docs.`,
+ );
+
+ console.log("✅ Migration complete!");
+ } catch (err) {
+ // bulkWrite with ordered:false can throw BulkWriteError with partial success
+ if (err?.result || err?.writeErrors) {
+ console.warn(
+ "Bulk write completed with some duplicate-key skips (safe on re-run):",
+ err.message,
+ );
+ console.log("✅ Migration complete (with skipped duplicates)!");
+ } else {
+ console.error("Migration failed:", err);
+ process.exit(1);
+ }
+ } finally {
+ await mongoose.connection.close();
+ }
+})();
diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/[postId]/community-post-page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/[postId]/community-post-page.tsx
index 821384f7b..16449afdb 100644
--- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/[postId]/community-post-page.tsx
+++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/[postId]/community-post-page.tsx
@@ -21,6 +21,7 @@ import NotFound from "@components/admin/not-found";
import { CommunityInfo } from "@components/community/info";
import MembershipStatus from "@components/community/membership-status";
import CommentSection from "@components/community/comment-section";
+import { ReactionsBar } from "@components/community/reactions-bar";
import dynamic from "next/dynamic";
import { useMediaLit, useToast } from "@courselit/components-library";
import {
@@ -35,7 +36,6 @@ import {
Trash,
FlagTriangleRight,
MessageSquare,
- ThumbsUp,
} from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
@@ -125,6 +125,20 @@ export default function CommunityPostPage({
}
}
likesCount
+ reactions {
+ emoji
+ count
+ hasReacted
+ reactors {
+ userId
+ name
+ avatar {
+ mediaId
+ file
+ thumbnail
+ }
+ }
+ }
commentsCount
updatedAt
hasLiked
@@ -169,23 +183,25 @@ export default function CommunityPostPage({
loadPost();
}, [loadPost]);
- const handleLike = async (targetPostId: string) => {
- setPost((prev) =>
- prev && prev.postId === targetPostId
- ? {
- ...prev,
- likesCount: prev.hasLiked
- ? prev.likesCount - 1
- : prev.likesCount + 1,
- hasLiked: !prev.hasLiked,
- }
- : prev,
- );
-
+ const handleReact = async (targetPostId: string, emoji: string) => {
const query = `
- mutation ($communityId: String!, $postId: String!) {
- togglePostLike(communityId: $communityId, postId: $postId) {
+ mutation ($communityId: String!, $postId: String!, $emoji: String!) {
+ togglePostReaction(communityId: $communityId, postId: $postId, emoji: $emoji) {
postId
+ reactions {
+ emoji
+ count
+ hasReacted
+ reactors {
+ userId
+ name
+ avatar {
+ mediaId
+ file
+ thumbnail
+ }
+ }
+ }
}
}
`;
@@ -195,18 +211,27 @@ export default function CommunityPostPage({
.setUrl(`${address.backend}/api/graph`)
.setPayload({
query,
- variables: { communityId, postId: targetPostId },
+ variables: { communityId, postId: targetPostId, emoji },
})
.setIsGraphQLEndpoint(true)
.build();
- await fetch.exec();
+ const response = await fetch.exec();
+ if (response.togglePostReaction) {
+ setPost((prev) =>
+ prev && prev.postId === targetPostId
+ ? {
+ ...prev,
+ reactions: response.togglePostReaction.reactions,
+ }
+ : prev,
+ );
+ }
} catch (err: any) {
toast({
title: TOAST_TITLE_ERROR,
description: err.message,
variant: "destructive",
});
- loadPost();
}
};
@@ -643,44 +668,43 @@ export default function CommunityPostPage({
))}
)}
-
-
-
-
+
+ handleReact(currentPost.postId, emoji)
+ }
+ showReplyButton
+ repliesCount={currentPost.commentsCount}
+ onReply={() => {
+ document
+ .getElementById("community-post-comments")
+ ?.scrollIntoView({
+ behavior: "smooth",
+ block: "start",
+ });
+ }}
+ />
{membership && (
- {
- setPost((prev) =>
- prev && prev.postId === targetPostId
- ? {
- ...prev,
- commentsCount: count,
- }
- : prev,
- );
- }}
- />
+
)}
diff --git a/apps/web/app/api/graph/route.ts b/apps/web/app/api/graph/route.ts
index 8835a3a2a..28d2549c5 100644
--- a/apps/web/app/api/graph/route.ts
+++ b/apps/web/app/api/graph/route.ts
@@ -3,9 +3,10 @@ import schema from "@/graphql";
import { graphql } from "graphql";
import { getAddress } from "@/lib/utils";
import User from "@models/User";
-import { auth } from "@/auth";
+import { getAuth } from "@/auth";
import { als } from "@/async-local-storage";
import { getCachedDomain } from "@/lib/domain-cache";
+import { getBackendAddress } from "@/app/actions";
async function updateLastActive(user: any) {
const dateNow = new Date();
@@ -28,9 +29,12 @@ export async function POST(req: NextRequest) {
);
}
+ const backendAddress = await getBackendAddress(req.headers);
+ const currentAuth = getAuth(backendAddress);
+
const [domain, session, body] = await Promise.all([
getCachedDomain(domainName),
- auth.api.getSession({ headers: req.headers }),
+ currentAuth.api.getSession({ headers: req.headers }),
req.json(),
]);
diff --git a/apps/web/components/community/__tests__/comment-section-reactions.test.tsx b/apps/web/components/community/__tests__/comment-section-reactions.test.tsx
new file mode 100644
index 000000000..67f742073
--- /dev/null
+++ b/apps/web/components/community/__tests__/comment-section-reactions.test.tsx
@@ -0,0 +1,234 @@
+import React from "react";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { Constants } from "@courselit/common-models";
+import CommentSection from "../comment-section";
+import { AddressContext, ProfileContext } from "@components/contexts";
+
+const mockToast = jest.fn();
+const mockExec = jest.fn();
+
+jest.mock("@courselit/components-library", () => ({
+ useToast: () => ({ toast: mockToast }),
+ Link: ({ children }: any) => {children},
+}));
+
+jest.mock("@courselit/utils", () => {
+ const actual = jest.requireActual("@courselit/utils");
+ return {
+ ...actual,
+ FetchBuilder: jest.fn().mockImplementation(() => ({
+ setUrl: jest.fn().mockReturnThis(),
+ setPayload: jest.fn().mockReturnThis(),
+ setIsGraphQLEndpoint: jest.fn().mockReturnThis(),
+ build: jest.fn().mockReturnValue({
+ exec: (...args: any[]) => mockExec(...args),
+ }),
+ })),
+ };
+});
+
+jest.mock("@/lib/hash-target", () => ({
+ focusHashTarget: jest.fn(),
+ scrollToHashTarget: jest.fn(() => false),
+}));
+
+jest.mock("../../ui/button", () => ({
+ Button: ({ children, ...props }: any) => (
+
+ ),
+}));
+
+jest.mock("../../ui/textarea", () => ({
+ Textarea: (props: any) => ,
+}));
+
+jest.mock("../../ui/avatar", () => ({
+ Avatar: ({ children }: any) => {children}
,
+ AvatarFallback: ({ children }: any) => {children}
,
+ AvatarImage: (props: any) =>
,
+}));
+
+jest.mock("../../ui/dropdown-menu", () => ({
+ DropdownMenu: ({ children }: any) => {children}
,
+ DropdownMenuContent: ({ children }: any) => {children}
,
+ DropdownMenuItem: ({ children, onClick }: any) => (
+
+ ),
+ DropdownMenuTrigger: ({ children }: any) => <>{children}>,
+}));
+
+jest.mock("../../ui/dialog", () => ({
+ Dialog: ({ children }: any) => {children}
,
+ DialogContent: ({ children }: any) => {children}
,
+ DialogTitle: ({ children }: any) => {children}
,
+ DialogDescription: ({ children }: any) => {children}
,
+ DialogFooter: ({ children }: any) => {children}
,
+}));
+
+jest.mock("../../ui/popover", () => ({
+ Popover: ({ children }: any) => {children}
,
+ PopoverTrigger: ({ children }: any) => <>{children}>,
+ PopoverContent: ({ children }: any) => {children}
,
+}));
+
+const profile = {
+ userId: "user-1",
+ name: "Test User",
+ email: "test@example.com",
+};
+
+const membership = {
+ status: Constants.MembershipStatus.ACTIVE,
+ role: Constants.MembershipRole.POST,
+ rejectionReason: undefined,
+};
+
+const baseComment = {
+ communityId: "comm-1",
+ postId: "post-1",
+ commentId: "comment-1",
+ content: "Hello comment",
+ user: {
+ userId: "author-1",
+ name: "Author",
+ avatar: {},
+ },
+ media: [],
+ likesCount: 0,
+ hasLiked: false,
+ reactions: [] as any[],
+ replies: [
+ {
+ replyId: "reply-1",
+ content: "Hello reply",
+ user: {
+ userId: "author-2",
+ name: "Replier",
+ avatar: {},
+ },
+ updatedAt: new Date().toISOString(),
+ likesCount: 0,
+ hasLiked: false,
+ reactions: [] as any[],
+ deleted: false,
+ },
+ ],
+ updatedAt: new Date().toISOString(),
+ deleted: false,
+};
+
+function renderSection() {
+ return render(
+
+
+
+
+ ,
+ );
+}
+
+describe("CommentSection reactions", () => {
+ beforeEach(() => {
+ mockExec.mockReset();
+ mockToast.mockReset();
+
+ // loadPost, loadComments
+ mockExec
+ .mockResolvedValueOnce({
+ post: { commentsCount: 1 },
+ })
+ .mockResolvedValueOnce({
+ comments: [baseComment],
+ });
+ });
+
+ it("optimistically shows a comment reaction pill when user reacts", async () => {
+ mockExec.mockResolvedValueOnce({
+ comment: {
+ ...baseComment,
+ reactions: [
+ {
+ emoji: "👍",
+ count: 1,
+ hasReacted: true,
+ reactors: [
+ {
+ userId: profile.userId,
+ name: profile.name,
+ avatar: {},
+ },
+ ],
+ },
+ ],
+ },
+ });
+
+ renderSection();
+
+ await waitFor(() => {
+ expect(screen.getByText("Hello comment")).toBeTruthy();
+ });
+
+ // Open picker on the comment (first add-reaction control)
+ // Click via mocked popover emoji button for 👍
+ // Emoji picker renders emoji buttons; click 👍 from the first picker set
+ const thumbsButtons = screen.getAllByRole("button", { name: "👍" });
+ fireEvent.click(thumbsButtons[0]);
+
+ await waitFor(() => {
+ // Optimistic or server: pill with count
+ expect(screen.getByText("1")).toBeTruthy();
+ });
+ });
+
+ it("optimistically shows a reply reaction when user reacts on a reply", async () => {
+ mockExec.mockResolvedValueOnce({
+ comment: {
+ ...baseComment,
+ replies: [
+ {
+ ...baseComment.replies[0],
+ reactions: [
+ {
+ emoji: "❤️",
+ count: 1,
+ hasReacted: true,
+ reactors: [
+ {
+ userId: profile.userId,
+ name: profile.name,
+ avatar: {},
+ },
+ ],
+ },
+ ],
+ likesCount: 1,
+ hasLiked: true,
+ },
+ ],
+ },
+ });
+
+ renderSection();
+
+ await waitFor(() => {
+ expect(screen.getByText("Hello reply")).toBeTruthy();
+ });
+
+ // Heart emoji buttons from pickers on comment + reply
+ const heartButtons = screen.getAllByRole("button", { name: "❤️" });
+ // Last picker set is under the reply bar
+ fireEvent.click(heartButtons[heartButtons.length - 1]);
+
+ await waitFor(() => {
+ expect(screen.getByText("1")).toBeTruthy();
+ });
+ });
+});
diff --git a/apps/web/components/community/__tests__/emoji-picker.test.tsx b/apps/web/components/community/__tests__/emoji-picker.test.tsx
new file mode 100644
index 000000000..90002fb82
--- /dev/null
+++ b/apps/web/components/community/__tests__/emoji-picker.test.tsx
@@ -0,0 +1,46 @@
+import React from "react";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { COMMUNITY_REACTION_EMOJIS } from "@courselit/common-models";
+import { EmojiPicker } from "../emoji-picker";
+
+jest.mock("../../ui/button", () => ({
+ Button: ({ children, ...props }: any) => (
+
+ ),
+}));
+
+jest.mock("../../ui/popover", () => ({
+ Popover: ({ children }: any) => {children}
,
+ PopoverTrigger: ({ children }: any) => <>{children}>,
+ PopoverContent: ({ children }: any) => (
+ {children}
+ ),
+}));
+
+describe("EmojiPicker", () => {
+ it("renders all allowed community reaction emojis", () => {
+ render();
+
+ for (const emoji of COMMUNITY_REACTION_EMOJIS) {
+ expect(screen.getByRole("button", { name: emoji })).toBeTruthy();
+ }
+ });
+
+ it("calls onEmojiSelect with the chosen emoji", () => {
+ const onEmojiSelect = jest.fn();
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: "🎉" }));
+ expect(onEmojiSelect).toHaveBeenCalledWith("🎉");
+ expect(onEmojiSelect).toHaveBeenCalledTimes(1);
+ });
+
+ it("renders custom trigger children", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByRole("button", { name: "Pick" })).toBeTruthy();
+ });
+});
diff --git a/apps/web/components/community/__tests__/reactions-bar.test.tsx b/apps/web/components/community/__tests__/reactions-bar.test.tsx
new file mode 100644
index 000000000..1a64c39f5
--- /dev/null
+++ b/apps/web/components/community/__tests__/reactions-bar.test.tsx
@@ -0,0 +1,124 @@
+import React from "react";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { CommunityReaction } from "@courselit/common-models";
+import { ReactionsBar } from "../reactions-bar";
+
+jest.mock("../../ui/button", () => ({
+ Button: ({ children, ...props }: any) => (
+
+ ),
+}));
+
+jest.mock("../emoji-picker", () => ({
+ EmojiPicker: ({
+ onEmojiSelect,
+ children,
+ }: {
+ onEmojiSelect: (emoji: string) => void;
+ children?: React.ReactNode;
+ }) => (
+
+ {children}
+
+
+ ),
+}));
+
+const sampleReactions: CommunityReaction[] = [
+ {
+ emoji: "👍",
+ count: 2,
+ hasReacted: false,
+ reactors: [
+ { userId: "u1", name: "Ada", avatar: {} as any },
+ { userId: "u2", name: "Bob", avatar: {} as any },
+ ],
+ },
+ {
+ emoji: "❤️",
+ count: 1,
+ hasReacted: true,
+ reactors: [{ userId: "me", name: "Me", avatar: {} as any }],
+ },
+];
+
+describe("ReactionsBar", () => {
+ it("renders reaction pills with counts and add-reaction control", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("👍")).toBeTruthy();
+ expect(screen.getByText("2")).toBeTruthy();
+ expect(screen.getByText("❤️")).toBeTruthy();
+ expect(screen.getByLabelText("Add reaction")).toBeTruthy();
+ });
+
+ it("calls onReact when an existing pill is clicked", () => {
+ const onReact = jest.fn();
+ render();
+
+ fireEvent.click(screen.getByText("👍").closest("button")!);
+ expect(onReact).toHaveBeenCalledWith("👍");
+ });
+
+ it("calls onReact when a new emoji is picked", () => {
+ const onReact = jest.fn();
+ render();
+
+ fireEvent.click(screen.getByLabelText("pick-party"));
+ expect(onReact).toHaveBeenCalledWith("🎉");
+ });
+
+ it("shows reactor names on hover", () => {
+ render(
+ ,
+ );
+
+ fireEvent.mouseEnter(screen.getByText("👍").closest("button")!);
+ expect(screen.getByText(/Ada, Bob/)).toBeTruthy();
+ });
+
+ it("keeps stable emoji order (picker order, not hasReacted-first)", () => {
+ // Heart is hasReacted but should still appear after thumbs in fixed order
+ const { container } = render(
+ ,
+ );
+ const pills = Array.from(container.querySelectorAll("button")).filter(
+ (btn) =>
+ btn.textContent?.includes("👍") ||
+ btn.textContent?.includes("❤️"),
+ );
+
+ // First reaction pill should be 👍 (index 0 in COMMUNITY_REACTION_EMOJIS)
+ // but list only has active ones - order among active is still 👍 then ❤️
+ const texts = pills.map((p) => p.textContent || "");
+ const thumbsIdx = texts.findIndex((t) => t.includes("👍"));
+ const heartIdx = texts.findIndex((t) => t.includes("❤️"));
+ expect(thumbsIdx).toBeGreaterThanOrEqual(0);
+ expect(heartIdx).toBeGreaterThan(thumbsIdx);
+ });
+
+ it("renders reply control when enabled", () => {
+ const onReply = jest.fn();
+ render(
+ ,
+ );
+
+ expect(screen.getByText("3")).toBeTruthy();
+ fireEvent.click(screen.getByText("3").closest("button")!);
+ expect(onReply).toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/components/community/comment-section.tsx b/apps/web/components/community/comment-section.tsx
index 4e371b84b..93c08e8da 100644
--- a/apps/web/components/community/comment-section.tsx
+++ b/apps/web/components/community/comment-section.tsx
@@ -9,10 +9,77 @@ import {
CommunityComment,
CommunityCommentReply,
CommunityPost,
+ CommunityReaction,
+ compareCommunityReactionsStable,
Membership,
} from "@courselit/common-models";
import { focusHashTarget, scrollToHashTarget } from "@/lib/hash-target";
+function toggleReactionLocally(
+ reactions: CommunityReaction[] | undefined,
+ emoji: string,
+ userId: string,
+ userName?: string,
+): CommunityReaction[] {
+ const list = [...(reactions || [])];
+ const idx = list.findIndex((r) => r.emoji === emoji);
+
+ if (idx === -1) {
+ const next: CommunityReaction[] = [
+ ...list,
+ {
+ emoji,
+ count: 1,
+ hasReacted: true,
+ reactors: [
+ {
+ userId,
+ name: userName,
+ avatar: {} as CommunityReaction["reactors"][number]["avatar"],
+ },
+ ],
+ },
+ ];
+ return next.sort(compareCommunityReactionsStable);
+ }
+
+ const existing = list[idx];
+ if (existing.hasReacted) {
+ const nextCount = existing.count - 1;
+ if (nextCount <= 0) {
+ return list.filter((_, i) => i !== idx);
+ }
+ return list.map((r, i) =>
+ i === idx
+ ? {
+ ...r,
+ count: nextCount,
+ hasReacted: false,
+ reactors: r.reactors.filter((x) => x.userId !== userId),
+ }
+ : r,
+ );
+ }
+
+ return list.map((r, i) =>
+ i === idx
+ ? {
+ ...r,
+ count: r.count + 1,
+ hasReacted: true,
+ reactors: [
+ ...r.reactors,
+ {
+ userId,
+ name: userName,
+ avatar: {} as CommunityReaction["reactors"][number]["avatar"],
+ },
+ ],
+ }
+ : r,
+ );
+}
+
const focusCommentTarget = (targetId: string) => {
focusHashTarget({
targetId,
@@ -20,6 +87,75 @@ const focusCommentTarget = (targetId: string) => {
});
};
+const REACTIONS_FRAGMENT = `
+ reactions {
+ emoji
+ count
+ hasReacted
+ reactors {
+ userId
+ name
+ avatar {
+ mediaId
+ file
+ thumbnail
+ }
+ }
+ }
+`;
+
+const REPLY_FIELDS = `
+ replyId
+ content
+ user {
+ userId
+ name
+ avatar {
+ mediaId
+ file
+ thumbnail
+ }
+ }
+ updatedAt
+ likesCount
+ hasLiked
+ ${REACTIONS_FRAGMENT}
+ deleted
+`;
+
+const COMMENT_FIELDS = `
+ communityId
+ postId
+ commentId
+ content
+ user {
+ userId
+ name
+ avatar {
+ mediaId
+ file
+ thumbnail
+ }
+ }
+ media {
+ type
+ media {
+ mediaId
+ file
+ thumbnail
+ size
+ }
+ }
+ likesCount
+ ${REACTIONS_FRAGMENT}
+ replies {
+ ${REPLY_FIELDS}
+ }
+ hasLiked
+ updatedAt
+ deleted
+`;
+
export default function CommentSection({
communityId,
postId,
@@ -116,49 +252,7 @@ export default function CommentSection({
const query = `
query ($communityId: String!, $postId: String!) {
comments: getComments(communityId: $communityId, postId: $postId) {
- communityId
- postId
- commentId
- content
- user {
- userId
- name
- avatar {
- mediaId
- file
- thumbnail
- }
- }
- media {
- type
- media {
- mediaId
- file
- thumbnail
- size
- }
- }
- likesCount
- replies {
- replyId
- content
- user {
- userId
- name
- avatar {
- mediaId
- file
- thumbnail
- }
- }
- updatedAt
- likesCount
- hasLiked
- deleted
- }
- hasLiked
- updatedAt
- deleted
+ ${COMMENT_FIELDS}
}
}
`;
@@ -193,49 +287,7 @@ export default function CommentSection({
const query = `
mutation ($communityId: String!, $postId: String!, $content: String!) {
comment: postComment(communityId: $communityId, postId: $postId, content: $content) {
- communityId
- postId
- commentId
- content
- user {
- userId
- name
- avatar {
- mediaId
- file
- thumbnail
- }
- }
- media {
- type
- media {
- mediaId
- file
- thumbnail
- size
- }
- }
- likesCount
- replies {
- replyId
- content
- user {
- userId
- name
- avatar {
- mediaId
- file
- thumbnail
- }
- }
- updatedAt
- likesCount
- hasLiked
- deleted
- }
- hasLiked
- updatedAt
- deleted
+ ${COMMENT_FIELDS}
}
}
`;
@@ -290,49 +342,7 @@ export default function CommentSection({
const query = `
mutation ($communityId: String!, $postId: String!, $commentId: String!, $content: String!, $parentReplyId: String, $media: [CommunityPostInputMedia]) {
comment: postComment(communityId: $communityId, postId: $postId, parentCommentId: $commentId, content: $content, parentReplyId: $parentReplyId, media: $media) {
- communityId
- postId
- commentId
- content
- user {
- userId
- name
- avatar {
- mediaId
- file
- thumbnail
- }
- }
- media {
- type
- media {
- mediaId
- file
- thumbnail
- size
- }
- }
- likesCount
- replies {
- replyId
- content
- user {
- userId
- name
- avatar {
- mediaId
- file
- thumbnail
- }
- }
- updatedAt
- likesCount
- hasLiked
- deleted
- }
- hasLiked
- updatedAt
- deleted
+ ${COMMENT_FIELDS}
}
}
`;
@@ -378,53 +388,31 @@ export default function CommentSection({
}
};
- const handleCommentLike = async (commentId: string) => {
+ const handleCommentReact = async (commentId: string, emoji: string) => {
+ const userId = profile?.userId;
+ if (!userId) return;
+
+ // Optimistic update so the pill appears immediately
+ setComments((prev) =>
+ prev.map((c) =>
+ c.commentId === commentId
+ ? {
+ ...c,
+ reactions: toggleReactionLocally(
+ c.reactions,
+ emoji,
+ userId,
+ profile?.name,
+ ),
+ }
+ : c,
+ ),
+ );
+
const query = `
- mutation ($communityId: String!, $postId: String!, $commentId: String!) {
- comment: toggleCommentLike(communityId: $communityId, postId: $postId, commentId: $commentId) {
- communityId
- postId
- commentId
- content
- user {
- userId
- name
- avatar {
- mediaId
- file
- thumbnail
- }
- }
- media {
- type
- media {
- mediaId
- file
- thumbnail
- size
- }
- }
- likesCount
- replies {
- replyId
- content
- user {
- userId
- name
- avatar {
- mediaId
- file
- thumbnail
- }
- }
- updatedAt
- likesCount
- hasLiked
- deleted
- }
- hasLiked
- updatedAt
- deleted
+ mutation ($communityId: String!, $postId: String!, $commentId: String!, $emoji: String!) {
+ comment: toggleCommentReaction(communityId: $communityId, postId: $postId, commentId: $commentId, emoji: $emoji) {
+ ${COMMENT_FIELDS}
}
}
`;
@@ -436,6 +424,7 @@ export default function CommentSection({
communityId,
postId,
commentId,
+ emoji,
},
})
.setIsGraphQLEndpoint(true)
@@ -447,6 +436,8 @@ export default function CommentSection({
replaceComment(response.comment);
}
} catch (err: any) {
+ // Re-sync from server on failure
+ loadComments();
toast({
title: "Error",
description: err.message,
@@ -454,52 +445,41 @@ export default function CommentSection({
}
};
- const handleReplyLike = async (commentId: string, replyId: string) => {
+ const handleReplyReact = async (
+ commentId: string,
+ emoji: string,
+ replyId: string,
+ ) => {
+ const userId = profile?.userId;
+ if (!userId) return;
+
+ // Optimistic update on the specific reply
+ setComments((prev) =>
+ prev.map((c) => {
+ if (c.commentId !== commentId) return c;
+ return {
+ ...c,
+ replies: (c.replies || []).map((r) =>
+ r.replyId === replyId
+ ? {
+ ...r,
+ reactions: toggleReactionLocally(
+ r.reactions,
+ emoji,
+ userId,
+ profile?.name,
+ ),
+ }
+ : r,
+ ),
+ };
+ }),
+ );
+
const query = `
- mutation ($communityId: String!, $postId: String!, $commentId: String!, $replyId: String!) {
- comment: toggleCommentReplyLike(communityId: $communityId, postId: $postId, commentId: $commentId, replyId: $replyId) {
- communityId
- postId
- commentId
- content
- user {
- userId
- name
- avatar {
- mediaId
- file
- thumbnail
- }
- }
- media {
- type
- media {
- mediaId
- file
- thumbnail
- }
- }
- likesCount
- replies {
- replyId
- content
- user {
- userId
- name
- avatar {
- mediaId
- file
- thumbnail
- }
- }
- updatedAt
- likesCount
- hasLiked
- deleted
- }
- hasLiked
- updatedAt
- deleted
+ mutation ($communityId: String!, $postId: String!, $commentId: String!, $replyId: String!, $emoji: String!) {
+ comment: toggleCommentReplyReaction(communityId: $communityId, postId: $postId, commentId: $commentId, replyId: $replyId, emoji: $emoji) {
+ ${COMMENT_FIELDS}
}
}
`;
@@ -512,6 +492,7 @@ export default function CommentSection({
postId,
commentId,
replyId,
+ emoji,
},
})
.setIsGraphQLEndpoint(true)
@@ -523,6 +504,7 @@ export default function CommentSection({
replaceComment(response.comment);
}
} catch (err: any) {
+ loadComments();
toast({
title: "Error",
description: err.message,
@@ -536,49 +518,7 @@ export default function CommentSection({
const query = `
mutation ($communityId: String!, $postId: String!, $commentId: String!, $replyId: String) {
comment: deleteComment(communityId: $communityId, postId: $postId, commentId: $commentId, replyId: $replyId) {
- communityId
- postId
- commentId
- content
- user {
- userId
- name
- avatar {
- mediaId
- file
- thumbnail
- }
- }
- media {
- type
- media {
- mediaId
- file
- thumbnail
- size
- }
- }
- likesCount
- replies {
- replyId
- content
- user {
- userId
- name
- avatar {
- mediaId
- file
- thumbnail
- }
- }
- updatedAt
- likesCount
- hasLiked
- deleted
- }
- hasLiked
- updatedAt
- deleted
+ ${COMMENT_FIELDS}
}
}
`;
@@ -615,7 +555,18 @@ export default function CommentSection({
const replaceComment = (comment: CommunityComment) => {
setComments((prevComments) =>
prevComments.map((c) =>
- c.commentId === comment.commentId ? comment : c,
+ c.commentId === comment.commentId
+ ? {
+ ...comment,
+ // Ensure new array identity so nested reply bars re-render
+ replies: comment.replies
+ ? comment.replies.map((r) => ({ ...r }))
+ : [],
+ reactions: comment.reactions
+ ? [...comment.reactions]
+ : [],
+ }
+ : c,
),
);
};
@@ -635,11 +586,15 @@ export default function CommentSection({
key={comment.commentId}
membership={membership}
comment={comment}
- onLike={(commentId: string, replyId?: string) => {
+ onReact={(
+ commentId: string,
+ emoji: string,
+ replyId?: string,
+ ) => {
if (replyId) {
- handleReplyLike(commentId, replyId);
+ handleReplyReact(commentId, emoji, replyId);
} else {
- handleCommentLike(commentId);
+ handleCommentReact(commentId, emoji);
}
}}
onReply={(commentId, content, parentReplyId?: string) =>
diff --git a/apps/web/components/community/comment.tsx b/apps/web/components/community/comment.tsx
index a936c4b17..b691f7165 100644
--- a/apps/web/components/community/comment.tsx
+++ b/apps/web/components/community/comment.tsx
@@ -2,13 +2,7 @@ import { useContext, useState } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
-import {
- ThumbsUp,
- MessageSquare,
- MoreVertical,
- FlagTriangleRight,
- Trash,
-} from "lucide-react";
+import { MoreVertical, FlagTriangleRight, Trash } from "lucide-react";
import {
CommunityComment,
CommunityCommentReply,
@@ -34,6 +28,7 @@ import { isCommunityComment } from "./utils";
import { DELETED_COMMENT_PLACEHOLDER } from "@ui-config/strings";
import { useToast } from "@courselit/components-library";
import { FetchBuilder } from "@courselit/utils";
+import { ReactionsBar } from "./reactions-bar";
type CommentOrReply =
| CommunityComment
@@ -42,7 +37,7 @@ type CommentOrReply =
interface CommentProps {
communityId: string;
comment: CommentOrReply;
- onLike: (commentId: string, replyId?: string) => void;
+ onReact: (commentId: string, emoji: string, replyId?: string) => void;
onReply: (
commentId: string,
content: string,
@@ -57,7 +52,7 @@ interface CommentProps {
export function Comment({
communityId,
comment,
- onLike,
+ onReact,
onReply,
onDelete,
membership,
@@ -254,25 +249,23 @@ export function Comment({
comment.content
)}
-
-
-
+
+ {
+ if (isCommunityComment(comment)) {
+ onReact(comment.commentId, emoji);
+ } else {
+ onReact(
+ comment.commentId,
+ emoji,
+ comment.replyId,
+ );
+ }
+ }}
+ showReplyButton
+ onReply={() => setIsReplying(!isReplying)}
+ />
@@ -325,7 +318,9 @@ export function Comment({
...reply,
commentId: comment.commentId,
}}
- onLike={() => onLike(comment.commentId, reply.replyId)}
+ onReact={(commentId, emoji, replyId) =>
+ onReact(commentId, emoji, reply.replyId)
+ }
onReply={onReply}
onDelete={onDelete}
membership={membership}
diff --git a/apps/web/components/community/emoji-picker.tsx b/apps/web/components/community/emoji-picker.tsx
new file mode 100644
index 000000000..f0a3812f7
--- /dev/null
+++ b/apps/web/components/community/emoji-picker.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { COMMUNITY_REACTION_EMOJIS } from "@courselit/common-models";
+
+interface EmojiPickerProps {
+ onEmojiSelect: (emoji: string) => void;
+ children?: React.ReactNode;
+}
+
+export function EmojiPicker({ onEmojiSelect, children }: EmojiPickerProps) {
+ const [open, setOpen] = useState(false);
+
+ return (
+
+
+ {children || (
+
+ )}
+
+
+
+ {COMMUNITY_REACTION_EMOJIS.map((emoji) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/web/components/community/index.tsx b/apps/web/components/community/index.tsx
index 57d725480..54fafe8c9 100644
--- a/apps/web/components/community/index.tsx
+++ b/apps/web/components/community/index.tsx
@@ -160,6 +160,20 @@ export function CommunityForum({
}
}
likesCount
+ reactions {
+ emoji
+ count
+ hasReacted
+ reactors {
+ userId
+ name
+ avatar {
+ mediaId
+ file
+ thumbnail
+ }
+ }
+ }
commentsCount
updatedAt
hasLiked
@@ -256,27 +270,31 @@ export function CommunityForum({
);
};
- const handleLike = async (postId: string, e?: React.MouseEvent) => {
+ const handleReact = async (
+ postId: string,
+ emoji: string,
+ e?: React.MouseEvent,
+ ) => {
e?.stopPropagation();
- setPosts((prevPosts) =>
- prevPosts.map((post) =>
- post.postId === postId
- ? {
- ...post,
- likesCount: post.hasLiked
- ? post.likesCount - 1
- : post.likesCount + 1,
- hasLiked: !post.hasLiked,
- }
- : post,
- ),
- );
-
const query = `
- mutation ($communityId: String!, $postId: String!) {
- togglePostLike(communityId: $communityId, postId: $postId) {
+ mutation ($communityId: String!, $postId: String!, $emoji: String!) {
+ togglePostReaction(communityId: $communityId, postId: $postId, emoji: $emoji) {
postId
+ reactions {
+ emoji
+ count
+ hasReacted
+ reactors {
+ userId
+ name
+ avatar {
+ mediaId
+ file
+ thumbnail
+ }
+ }
+ }
}
}
`;
@@ -285,11 +303,24 @@ export function CommunityForum({
.setUrl(`${address.backend}/api/graph`)
.setPayload({
query,
- variables: { postId, communityId: id },
+ variables: { postId, communityId: id, emoji },
})
.setIsGraphQLEndpoint(true)
.build();
- await fetch.exec();
+ const response = await fetch.exec();
+ if (response.togglePostReaction) {
+ setPosts((prevPosts) =>
+ prevPosts.map((post) =>
+ post.postId === postId
+ ? {
+ ...post,
+ reactions:
+ response.togglePostReaction.reactions,
+ }
+ : post,
+ ),
+ );
+ }
} catch (err) {
console.error(err.message);
toast({
@@ -955,7 +986,7 @@ export function CommunityForum({
)
}
onTogglePin={togglePin}
- onLike={handleLike}
+ onReact={handleReact}
/>
))
) : (
diff --git a/apps/web/components/community/post-card.tsx b/apps/web/components/community/post-card.tsx
index 8cceb3f1e..111c38db9 100644
--- a/apps/web/components/community/post-card.tsx
+++ b/apps/web/components/community/post-card.tsx
@@ -15,10 +15,11 @@ import {
} from "@courselit/page-blocks";
import { CommunityMedia, CommunityPost } from "@courselit/common-models";
import { capitalize, truncate } from "@courselit/utils";
-import { MessageSquare, Pin, ThumbsUp } from "lucide-react";
+import { Pin } from "lucide-react";
import Link from "next/link";
import { useContext } from "react";
import { ThemeContext } from "@components/contexts";
+import { ReactionsBar } from "./reactions-bar";
interface CommunityPostCardProps {
post: CommunityPost;
@@ -29,7 +30,7 @@ interface CommunityPostCardProps {
renderMediaPreview: (media: CommunityMedia) => React.ReactNode;
onOpen: (postId: string) => void;
onTogglePin?: (postId: string, e?: React.MouseEvent) => void;
- onLike?: (postId: string, e?: React.MouseEvent) => void;
+ onReact?: (postId: string, emoji: string, e?: React.MouseEvent) => void;
}
export default function CommunityPostCard({
@@ -41,7 +42,7 @@ export default function CommunityPostCard({
renderMediaPreview,
onOpen,
onTogglePin,
- onLike,
+ onReact,
}: CommunityPostCardProps) {
const { theme } = useContext(ThemeContext);
@@ -137,25 +138,13 @@ export default function CommunityPostCard({
)}
-
-
-
-
+ onReact?.(post.postId, emoji)}
+ showReplyButton
+ onReply={() => onOpen(post.postId)}
+ repliesCount={post.commentsCount}
+ />
);
diff --git a/apps/web/components/community/reactions-bar.tsx b/apps/web/components/community/reactions-bar.tsx
new file mode 100644
index 000000000..64d7801b6
--- /dev/null
+++ b/apps/web/components/community/reactions-bar.tsx
@@ -0,0 +1,173 @@
+"use client";
+
+import { useState, useRef, useEffect } from "react";
+import { CommunityReaction } from "@courselit/common-models";
+import { SmilePlus, Reply } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { EmojiPicker } from "./emoji-picker";
+
+interface ReactionsBarProps {
+ reactions: CommunityReaction[];
+ onReact: (emoji: string) => void;
+ /**
+ * Optional reply button rendered at the end of the bar (after reactions).
+ */
+ onReply?: () => void;
+ /**
+ * Whether to show the reply button. Defaults to false.
+ */
+ showReplyButton?: boolean;
+ /**
+ * Number of replies to show on the reply button (post only).
+ */
+ repliesCount?: number;
+}
+
+export function ReactionsBar({
+ reactions,
+ onReact,
+ onReply,
+ showReplyButton = false,
+ repliesCount,
+}: ReactionsBarProps) {
+ const [hoveredEmoji, setHoveredEmoji] = useState(null);
+ const [tooltipPos, setTooltipPos] = useState<{
+ top: number;
+ left: number;
+ } | null>(null);
+ const tooltipRef = useRef(null);
+ const timeoutRef = useRef | null>(null);
+
+ const handleMouseEnter = (
+ emoji: string,
+ e: React.MouseEvent,
+ ) => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+ const rect = e.currentTarget.getBoundingClientRect();
+ setTooltipPos({
+ top: rect.top - 8,
+ left: rect.left + rect.width / 2,
+ });
+ setHoveredEmoji(emoji);
+ };
+
+ const handleMouseLeave = () => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+ timeoutRef.current = setTimeout(() => {
+ setHoveredEmoji(null);
+ setTooltipPos(null);
+ }, 200);
+ };
+
+ useEffect(() => {
+ return () => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+ };
+ }, []);
+
+ const activeReactions = reactions.filter((r) => r.count > 0);
+ const hoveredReaction = reactions.find((r) => r.emoji === hoveredEmoji);
+
+ return (
+ <>
+
+ {/* Active reaction pills — left of the emoji picker */}
+ {activeReactions.map((reaction) => (
+
+ ))}
+
+ {
+ onReact(emoji);
+ }}
+ >
+
+
+
+ {showReplyButton && (
+
+ )}
+
+
+ {hoveredReaction && hoveredEmoji && tooltipPos && (
+ {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+ }}
+ onMouseLeave={handleMouseLeave}
+ >
+
+ {hoveredReaction.reactors.length > 0
+ ? hoveredReaction.reactors
+ .map((r) => r.name || r.userId)
+ .join(", ")
+ : "..."}
+
+
+ )}
+ >
+ );
+}
diff --git a/apps/web/components/community/utils.ts b/apps/web/components/community/utils.ts
index 38b257911..02a2a2c5b 100644
--- a/apps/web/components/community/utils.ts
+++ b/apps/web/components/community/utils.ts
@@ -4,7 +4,14 @@ import {
} from "@courselit/common-models";
export function isCommunityComment(
- comment: CommunityComment | CommunityCommentReply,
+ comment:
+ | CommunityComment
+ | (CommunityCommentReply & { commentId?: string }),
): comment is CommunityComment {
+ // Replies are rendered as Comment with an injected commentId; identify them
+ // by replyId so reaction handlers hit toggleCommentReplyReaction.
+ if ("replyId" in comment && Boolean(comment.replyId)) {
+ return false;
+ }
return (comment as CommunityComment).postId !== undefined;
}
diff --git a/apps/web/graphql/communities/__tests__/logic.test.ts b/apps/web/graphql/communities/__tests__/logic.test.ts
index 0ffd9dcae..324b4436a 100644
--- a/apps/web/graphql/communities/__tests__/logic.test.ts
+++ b/apps/web/graphql/communities/__tests__/logic.test.ts
@@ -10,10 +10,19 @@ import {
getFeedCount,
getCommunityReports,
reportCommunityContent,
+ togglePostReaction,
+ togglePostLike,
+ toggleCommentReaction,
+ toggleCommentReplyReaction,
+ getReactionsForEntity,
+ deleteCommunityPost,
+ deleteCommunityPosts,
+ deleteComment,
} from "../logic";
import CommunityModel from "@models/Community";
import CommunityPostModel from "@models/CommunityPost";
import CommunityCommentModel from "@models/CommunityComment";
+import CommunityReactionModel from "@models/CommunityReaction";
import CommunityReportModel from "@models/CommunityReport";
import MembershipModel from "@models/Membership";
import PaymentPlanModel from "@models/PaymentPlan";
@@ -21,7 +30,11 @@ import PageModel from "@models/Page";
import DomainModel from "@models/Domain";
import UserModel from "@models/User";
import constants from "@/config/constants";
-import { Constants, TextEditorContent } from "@courselit/common-models";
+import {
+ COMMUNITY_HEART_EMOJI,
+ Constants,
+ TextEditorContent,
+} from "@courselit/common-models";
jest.mock("@/services/queue");
@@ -251,14 +264,14 @@ describe("Community Logic - Comment Count Tests", () => {
replyId: "reply-1",
userId: adminUser.userId,
content: "First reply",
- likes: [],
+ reactions: {},
deleted: false,
},
{
replyId: "reply-2",
userId: regularUser.userId,
content: "Second reply",
- likes: [],
+ reactions: {},
deleted: false,
},
],
@@ -283,14 +296,14 @@ describe("Community Logic - Comment Count Tests", () => {
replyId: "reply-1-1",
userId: adminUser.userId,
content: "Reply to first",
- likes: [],
+ reactions: {},
deleted: false,
},
{
replyId: "reply-1-2",
userId: regularUser.userId,
content: "Another reply to first",
- likes: [],
+ reactions: {},
deleted: false,
},
],
@@ -309,7 +322,7 @@ describe("Community Logic - Comment Count Tests", () => {
replyId: "reply-2-1",
userId: regularUser.userId,
content: "Reply to second",
- likes: [],
+ reactions: {},
deleted: false,
},
],
@@ -373,7 +386,7 @@ describe("Community Logic - Comment Count Tests", () => {
replyId: "reply-active",
userId: adminUser.userId,
content: "Active reply",
- likes: [],
+ reactions: {},
deleted: false,
},
],
@@ -484,6 +497,7 @@ describe("Community Logic - Feed Tests", () => {
});
afterEach(async () => {
+ await CommunityReactionModel.deleteMany({ domain: testDomain._id });
await CommunityCommentModel.deleteMany({ domain: testDomain._id });
await CommunityPostModel.deleteMany({ domain: testDomain._id });
await MembershipModel.deleteMany({
@@ -493,6 +507,7 @@ describe("Community Logic - Feed Tests", () => {
});
afterAll(async () => {
+ await CommunityReactionModel.deleteMany({ domain: testDomain._id });
await CommunityCommentModel.deleteMany({ domain: testDomain._id });
await CommunityPostModel.deleteMany({ domain: testDomain._id });
await MembershipModel.deleteMany({ domain: testDomain._id });
@@ -538,7 +553,6 @@ describe("Community Logic - Feed Tests", () => {
title: "Older post",
content: "Older content",
category: "General",
- likes: [],
deleted: false,
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
},
@@ -550,12 +564,17 @@ describe("Community Logic - Feed Tests", () => {
title: "Newest post",
content: "Newest content",
category: "General",
- likes: [regularUser.userId],
deleted: false,
updatedAt: new Date("2026-02-01T00:00:00.000Z"),
},
]);
+ await togglePostLike({
+ ctx: mockCtx,
+ communityId: communityTwo.communityId,
+ postId: "feed-post-2",
+ });
+
const feed = await getFeed({ ctx: mockCtx, page: 1, limit: 1 });
const count = await getFeedCount({ ctx: mockCtx });
@@ -592,7 +611,7 @@ describe("Community Logic - Feed Tests", () => {
title: "Pending membership post",
content: "Should not appear",
category: "General",
- likes: [],
+ reactions: {},
deleted: false,
});
@@ -761,3 +780,707 @@ describe("Community Logic - Enabled Communities Count Tests", () => {
expect(count).toBe(2);
});
});
+
+describe("Community Logic - Reactions", () => {
+ let testDomain: any;
+ let adminUser: any;
+ let regularUser: any;
+ let community: any;
+ let post: any;
+ let adminCtx: any;
+ let regularCtx: any;
+
+ beforeAll(async () => {
+ const suffix = `rxn-${Date.now()}`;
+ testDomain = await DomainModel.create({
+ name: `test-domain-${suffix}`,
+ email: `rxn-${suffix}@example.com`,
+ });
+
+ adminUser = await UserModel.create({
+ domain: testDomain._id,
+ userId: `admin-${suffix}`,
+ email: `admin-${suffix}@example.com`,
+ name: "Admin User",
+ permissions: [constants.permissions.manageCommunity],
+ active: true,
+ unsubscribeToken: `unsub-admin-${suffix}`,
+ });
+
+ regularUser = await UserModel.create({
+ domain: testDomain._id,
+ userId: `regular-${suffix}`,
+ email: `regular-${suffix}@example.com`,
+ name: "Regular User",
+ permissions: [],
+ active: true,
+ unsubscribeToken: `unsub-regular-${suffix}`,
+ });
+
+ await PaymentPlanModel.create({
+ domain: testDomain._id,
+ planId: `plan-${suffix}`,
+ userId: adminUser.userId,
+ entityId: "internal",
+ entityType: Constants.MembershipEntityType.COURSE,
+ type: "free",
+ name: constants.internalPaymentPlanName,
+ internal: true,
+ interval: "monthly",
+ cost: 0,
+ currencyISOCode: "USD",
+ });
+
+ await PageModel.create({
+ domain: testDomain._id,
+ pageId: `page-${suffix}`,
+ type: constants.communityPage,
+ name: "Community Page",
+ entityId: `comm-${suffix}`,
+ layout: [],
+ creatorId: adminUser.userId,
+ });
+
+ community = await CommunityModel.create({
+ domain: testDomain._id,
+ communityId: `comm-${suffix}`,
+ name: "Reactions Community",
+ pageId: `page-${suffix}`,
+ slug: `comm-${suffix}`,
+ enabled: true,
+ categories: ["General"],
+ autoAcceptMembers: true,
+ });
+
+ await MembershipModel.create([
+ {
+ domain: testDomain._id,
+ membershipId: `mem-admin-${suffix}`,
+ userId: adminUser.userId,
+ entityId: community.communityId,
+ entityType: Constants.MembershipEntityType.COMMUNITY,
+ paymentPlanId: `plan-${suffix}`,
+ sessionId: `session-admin-${suffix}`,
+ status: Constants.MembershipStatus.ACTIVE,
+ role: Constants.MembershipRole.MODERATE,
+ },
+ {
+ domain: testDomain._id,
+ membershipId: `mem-regular-${suffix}`,
+ userId: regularUser.userId,
+ entityId: community.communityId,
+ entityType: Constants.MembershipEntityType.COMMUNITY,
+ paymentPlanId: `plan-${suffix}`,
+ sessionId: `session-regular-${suffix}`,
+ status: Constants.MembershipStatus.ACTIVE,
+ role: Constants.MembershipRole.POST,
+ },
+ ]);
+
+ post = await CommunityPostModel.create({
+ domain: testDomain._id,
+ userId: adminUser.userId,
+ communityId: community.communityId,
+ postId: `post-${suffix}`,
+ title: "Reaction post",
+ content: doc("React to me"),
+ category: "General",
+ reactions: {},
+ deleted: false,
+ });
+
+ adminCtx = { user: adminUser, subdomain: testDomain } as any;
+ regularCtx = { user: regularUser, subdomain: testDomain } as any;
+ });
+
+ afterAll(async () => {
+ await CommunityReactionModel.deleteMany({ domain: testDomain._id });
+ await CommunityPostModel.deleteMany({ domain: testDomain._id });
+ await CommunityCommentModel.deleteMany({ domain: testDomain._id });
+ await MembershipModel.deleteMany({ domain: testDomain._id });
+ await CommunityModel.deleteMany({ domain: testDomain._id });
+ await PageModel.deleteMany({ domain: testDomain._id });
+ await PaymentPlanModel.deleteMany({ domain: testDomain._id });
+ await UserModel.deleteMany({ domain: testDomain._id });
+ await DomainModel.deleteMany({ _id: testDomain._id });
+ });
+
+ it("toggles a post reaction and reports hasReacted / count", async () => {
+ const reacted = await togglePostReaction({
+ ctx: regularCtx,
+ communityId: community.communityId,
+ postId: post.postId,
+ emoji: "👍",
+ });
+
+ expect(reacted.likesCount).toBe(0);
+ expect(reacted.hasLiked).toBe(false);
+
+ const reactions = await getReactionsForEntity({
+ entity: reacted,
+ ctx: regularCtx,
+ });
+ const thumbs = reactions.find((r) => r.emoji === "👍");
+ expect(thumbs).toMatchObject({
+ emoji: "👍",
+ count: 1,
+ hasReacted: true,
+ });
+ expect(thumbs?.reactors.map((r) => r.userId)).toContain(
+ regularUser.userId,
+ );
+
+ const unreacted = await togglePostReaction({
+ ctx: regularCtx,
+ communityId: community.communityId,
+ postId: post.postId,
+ emoji: "👍",
+ });
+ const after = await getReactionsForEntity({
+ entity: unreacted,
+ ctx: regularCtx,
+ });
+ expect(after.find((r) => r.emoji === "👍")).toBeUndefined();
+ });
+
+ it("maps togglePostLike to the heart reaction and derives likesCount/hasLiked", async () => {
+ const liked = await togglePostLike({
+ ctx: regularCtx,
+ communityId: community.communityId,
+ postId: post.postId,
+ });
+
+ expect(liked.hasLiked).toBe(true);
+ expect(liked.likesCount).toBe(1);
+
+ const reactions = await getReactionsForEntity({
+ entity: liked,
+ ctx: regularCtx,
+ });
+ const heart = reactions.find((r) => r.emoji === COMMUNITY_HEART_EMOJI);
+ expect(heart?.count).toBe(1);
+ expect(heart?.hasReacted).toBe(true);
+
+ await togglePostLike({
+ ctx: regularCtx,
+ communityId: community.communityId,
+ postId: post.postId,
+ });
+ });
+
+ it("allows multiple different emojis from the same user", async () => {
+ await togglePostReaction({
+ ctx: regularCtx,
+ communityId: community.communityId,
+ postId: post.postId,
+ emoji: COMMUNITY_HEART_EMOJI,
+ });
+ await togglePostReaction({
+ ctx: regularCtx,
+ communityId: community.communityId,
+ postId: post.postId,
+ emoji: "🎉",
+ });
+
+ const rows = await CommunityReactionModel.find({
+ domain: testDomain._id,
+ entityType: Constants.CommunityReactionEntityType.POST,
+ entityId: post.postId,
+ userId: regularUser.userId,
+ }).lean();
+ const emojis = rows.map((r) => r.emoji).sort();
+ expect(emojis).toEqual(["🎉", COMMUNITY_HEART_EMOJI].sort());
+
+ // cleanup
+ await togglePostReaction({
+ ctx: regularCtx,
+ communityId: community.communityId,
+ postId: post.postId,
+ emoji: COMMUNITY_HEART_EMOJI,
+ });
+ await togglePostReaction({
+ ctx: regularCtx,
+ communityId: community.communityId,
+ postId: post.postId,
+ emoji: "🎉",
+ });
+ });
+
+ it("rejects disallowed emojis", async () => {
+ await expect(
+ togglePostReaction({
+ ctx: regularCtx,
+ communityId: community.communityId,
+ postId: post.postId,
+ emoji: "🚀",
+ }),
+ ).rejects.toThrow("Invalid input");
+ });
+
+ it("toggles comment and reply reactions", async () => {
+ const comment = await CommunityCommentModel.create({
+ domain: testDomain._id,
+ communityId: community.communityId,
+ postId: post.postId,
+ commentId: `comment-rxn-${Date.now()}`,
+ userId: adminUser.userId,
+ content: "Comment body",
+ replies: [
+ {
+ replyId: `reply-rxn-${Date.now()}`,
+ userId: adminUser.userId,
+ content: "Reply body",
+ deleted: false,
+ },
+ ],
+ });
+
+ const afterComment = await toggleCommentReaction({
+ ctx: regularCtx,
+ communityId: community.communityId,
+ postId: post.postId,
+ commentId: comment.commentId,
+ emoji: "😄",
+ });
+ expect(afterComment.likesCount).toBe(0);
+ const commentReactions = await getReactionsForEntity({
+ entity: afterComment,
+ ctx: regularCtx,
+ });
+ expect(commentReactions.find((r) => r.emoji === "😄")?.count).toBe(1);
+
+ const replyId = comment.replies[0].replyId;
+ const afterReply = await toggleCommentReplyReaction({
+ ctx: regularCtx,
+ communityId: community.communityId,
+ postId: post.postId,
+ commentId: comment.commentId,
+ replyId,
+ emoji: COMMUNITY_HEART_EMOJI,
+ });
+ const reply = afterReply.replies.find(
+ (r: any) => r.replyId === replyId,
+ );
+ expect(reply?.likesCount).toBe(1);
+ expect(reply?.hasLiked).toBe(true);
+
+ const replyReactions = await getReactionsForEntity({
+ entity: reply,
+ ctx: regularCtx,
+ });
+ expect(
+ replyReactions.find((r) => r.emoji === COMMUNITY_HEART_EMOJI)
+ ?.hasReacted,
+ ).toBe(true);
+
+ // Critical: reply reactions live in the collection and survive re-format
+ const dbRows = await CommunityReactionModel.find({
+ domain: testDomain._id,
+ entityType: Constants.CommunityReactionEntityType.REPLY,
+ entityId: replyId,
+ }).lean();
+ expect(dbRows.map((r) => r.userId)).toContain(regularUser.userId);
+ expect(dbRows.map((r) => r.emoji)).toContain(COMMUNITY_HEART_EMOJI);
+
+ // Toggle off and confirm row is gone
+ await toggleCommentReplyReaction({
+ ctx: regularCtx,
+ communityId: community.communityId,
+ postId: post.postId,
+ commentId: comment.commentId,
+ replyId,
+ emoji: COMMUNITY_HEART_EMOJI,
+ });
+ const afterOff = await CommunityReactionModel.find({
+ domain: testDomain._id,
+ entityType: Constants.CommunityReactionEntityType.REPLY,
+ entityId: replyId,
+ emoji: COMMUNITY_HEART_EMOJI,
+ });
+ expect(afterOff).toHaveLength(0);
+ });
+
+ it("rejects unauthenticated reaction toggles", async () => {
+ const unauthCtx = { user: null, subdomain: testDomain } as any;
+ await expect(
+ togglePostReaction({
+ ctx: unauthCtx,
+ communityId: community.communityId,
+ postId: post.postId,
+ emoji: "👍",
+ }),
+ ).rejects.toThrow();
+ });
+
+ it("rejects reaction when user is not a community member", async () => {
+ const outsider = await UserModel.create({
+ domain: testDomain._id,
+ userId: `outsider-${Date.now()}`,
+ email: `outsider-${Date.now()}@example.com`,
+ name: "Outsider",
+ permissions: [],
+ active: true,
+ unsubscribeToken: `unsub-out-${Date.now()}`,
+ });
+ const outsiderCtx = { user: outsider, subdomain: testDomain } as any;
+
+ await expect(
+ togglePostReaction({
+ ctx: outsiderCtx,
+ communityId: community.communityId,
+ postId: post.postId,
+ emoji: "👍",
+ }),
+ ).rejects.toThrow();
+ });
+
+ it("rejects comment reaction when membership is COMMENT-ineligible", async () => {
+ // Create a user with no membership at all for comment path
+ const stranger = await UserModel.create({
+ domain: testDomain._id,
+ userId: `stranger-${Date.now()}`,
+ email: `stranger-${Date.now()}@example.com`,
+ name: "Stranger",
+ permissions: [],
+ active: true,
+ unsubscribeToken: `unsub-str-${Date.now()}`,
+ });
+ const strangerCtx = { user: stranger, subdomain: testDomain } as any;
+
+ const comment = await CommunityCommentModel.create({
+ domain: testDomain._id,
+ communityId: community.communityId,
+ postId: post.postId,
+ commentId: `comment-auth-${Date.now()}`,
+ userId: adminUser.userId,
+ content: "Auth check",
+ replies: [],
+ });
+
+ await expect(
+ toggleCommentReaction({
+ ctx: strangerCtx,
+ communityId: community.communityId,
+ postId: post.postId,
+ commentId: comment.commentId,
+ emoji: "👍",
+ }),
+ ).rejects.toThrow();
+ });
+
+ it("rejects reaction on missing post / comment / reply", async () => {
+ await expect(
+ togglePostReaction({
+ ctx: regularCtx,
+ communityId: community.communityId,
+ postId: "does-not-exist",
+ emoji: "👍",
+ }),
+ ).rejects.toThrow();
+
+ await expect(
+ toggleCommentReaction({
+ ctx: regularCtx,
+ communityId: community.communityId,
+ postId: post.postId,
+ commentId: "missing-comment",
+ emoji: "👍",
+ }),
+ ).rejects.toThrow();
+
+ const comment = await CommunityCommentModel.create({
+ domain: testDomain._id,
+ communityId: community.communityId,
+ postId: post.postId,
+ commentId: `comment-missing-reply-${Date.now()}`,
+ userId: adminUser.userId,
+ content: "Has no reply",
+ replies: [],
+ });
+
+ await expect(
+ toggleCommentReplyReaction({
+ ctx: regularCtx,
+ communityId: community.communityId,
+ postId: post.postId,
+ commentId: comment.commentId,
+ replyId: "missing-reply",
+ emoji: "👍",
+ }),
+ ).rejects.toThrow();
+ });
+
+ it("deletes reaction rows when a post is deleted", async () => {
+ const doomedPost = await CommunityPostModel.create({
+ domain: testDomain._id,
+ userId: adminUser.userId,
+ communityId: community.communityId,
+ postId: `post-doom-${Date.now()}`,
+ title: "Doomed",
+ content: doc("bye"),
+ category: "General",
+ deleted: false,
+ });
+
+ await togglePostReaction({
+ ctx: regularCtx,
+ communityId: community.communityId,
+ postId: doomedPost.postId,
+ emoji: "👍",
+ });
+
+ const comment = await CommunityCommentModel.create({
+ domain: testDomain._id,
+ communityId: community.communityId,
+ postId: doomedPost.postId,
+ commentId: `comment-doom-${Date.now()}`,
+ userId: adminUser.userId,
+ content: "on doomed post",
+ replies: [
+ {
+ replyId: `reply-doom-${Date.now()}`,
+ userId: adminUser.userId,
+ content: "reply",
+ deleted: false,
+ },
+ ],
+ });
+
+ await toggleCommentReaction({
+ ctx: regularCtx,
+ communityId: community.communityId,
+ postId: doomedPost.postId,
+ commentId: comment.commentId,
+ emoji: "😄",
+ });
+ await toggleCommentReplyReaction({
+ ctx: regularCtx,
+ communityId: community.communityId,
+ postId: doomedPost.postId,
+ commentId: comment.commentId,
+ replyId: comment.replies[0].replyId,
+ emoji: COMMUNITY_HEART_EMOJI,
+ });
+
+ const before = await CommunityReactionModel.countDocuments({
+ domain: testDomain._id,
+ postId: doomedPost.postId,
+ });
+ expect(before).toBeGreaterThanOrEqual(3);
+
+ await deleteCommunityPost({
+ ctx: adminCtx,
+ communityId: community.communityId,
+ postId: doomedPost.postId,
+ });
+
+ const after = await CommunityReactionModel.countDocuments({
+ domain: testDomain._id,
+ postId: doomedPost.postId,
+ });
+ expect(after).toBe(0);
+ });
+
+ it("purges reaction rows when a leaf comment is hard-deleted", async () => {
+ const leaf = await CommunityCommentModel.create({
+ domain: testDomain._id,
+ communityId: community.communityId,
+ postId: post.postId,
+ commentId: `comment-leaf-${Date.now()}`,
+ userId: adminUser.userId,
+ content: "Leaf comment",
+ replies: [],
+ });
+
+ await toggleCommentReaction({
+ ctx: regularCtx,
+ communityId: community.communityId,
+ postId: post.postId,
+ commentId: leaf.commentId,
+ emoji: "👍",
+ });
+
+ expect(
+ await CommunityReactionModel.countDocuments({
+ domain: testDomain._id,
+ entityType: Constants.CommunityReactionEntityType.COMMENT,
+ entityId: leaf.commentId,
+ }),
+ ).toBe(1);
+
+ await deleteComment({
+ ctx: adminCtx,
+ communityId: community.communityId,
+ postId: post.postId,
+ commentId: leaf.commentId,
+ });
+
+ expect(
+ await CommunityReactionModel.countDocuments({
+ domain: testDomain._id,
+ entityType: Constants.CommunityReactionEntityType.COMMENT,
+ entityId: leaf.commentId,
+ }),
+ ).toBe(0);
+ });
+
+ it("purges reaction rows when a leaf reply is hard-deleted", async () => {
+ const replyId = `reply-leaf-${Date.now()}`;
+ const parent = await CommunityCommentModel.create({
+ domain: testDomain._id,
+ communityId: community.communityId,
+ postId: post.postId,
+ commentId: `comment-parent-${Date.now()}`,
+ userId: adminUser.userId,
+ content: "Parent",
+ replies: [
+ {
+ replyId,
+ userId: adminUser.userId,
+ content: "Leaf reply",
+ deleted: false,
+ },
+ ],
+ });
+
+ await toggleCommentReplyReaction({
+ ctx: regularCtx,
+ communityId: community.communityId,
+ postId: post.postId,
+ commentId: parent.commentId,
+ replyId,
+ emoji: "🎉",
+ });
+
+ expect(
+ await CommunityReactionModel.countDocuments({
+ domain: testDomain._id,
+ entityType: Constants.CommunityReactionEntityType.REPLY,
+ entityId: replyId,
+ }),
+ ).toBe(1);
+
+ await deleteComment({
+ ctx: adminCtx,
+ communityId: community.communityId,
+ postId: post.postId,
+ commentId: parent.commentId,
+ replyId,
+ });
+
+ expect(
+ await CommunityReactionModel.countDocuments({
+ domain: testDomain._id,
+ entityType: Constants.CommunityReactionEntityType.REPLY,
+ entityId: replyId,
+ }),
+ ).toBe(0);
+ });
+
+ it("keeps reaction rows when a comment is soft-deleted", async () => {
+ const replyId = `reply-soft-${Date.now()}`;
+ const soft = await CommunityCommentModel.create({
+ domain: testDomain._id,
+ communityId: community.communityId,
+ postId: post.postId,
+ commentId: `comment-soft-${Date.now()}`,
+ userId: adminUser.userId,
+ content: "Soft delete me",
+ replies: [
+ {
+ replyId,
+ userId: adminUser.userId,
+ content: "child",
+ deleted: false,
+ },
+ ],
+ });
+
+ await toggleCommentReaction({
+ ctx: regularCtx,
+ communityId: community.communityId,
+ postId: post.postId,
+ commentId: soft.commentId,
+ emoji: "😢",
+ });
+
+ await deleteComment({
+ ctx: adminCtx,
+ communityId: community.communityId,
+ postId: post.postId,
+ commentId: soft.commentId,
+ });
+
+ // Soft-deleted: document remains, reactions retained
+ const stillThere = await CommunityCommentModel.findOne({
+ commentId: soft.commentId,
+ });
+ expect(stillThere?.deleted).toBe(true);
+ expect(
+ await CommunityReactionModel.countDocuments({
+ domain: testDomain._id,
+ entityType: Constants.CommunityReactionEntityType.COMMENT,
+ entityId: soft.commentId,
+ }),
+ ).toBe(1);
+ });
+
+ it("deletes reaction rows when community posts are bulk-deleted", async () => {
+ const bulkCommunityId = `comm-bulk-${Date.now()}`;
+ await CommunityModel.create({
+ domain: testDomain._id,
+ communityId: bulkCommunityId,
+ name: "Bulk delete community",
+ pageId: `page-bulk-${Date.now()}`,
+ slug: `slug-bulk-${Date.now()}`,
+ enabled: true,
+ categories: ["General"],
+ autoAcceptMembers: true,
+ });
+ await MembershipModel.create({
+ domain: testDomain._id,
+ membershipId: `mem-bulk-${Date.now()}`,
+ userId: regularUser.userId,
+ entityId: bulkCommunityId,
+ entityType: Constants.MembershipEntityType.COMMUNITY,
+ paymentPlanId: `plan-bulk`,
+ sessionId: `session-bulk`,
+ status: Constants.MembershipStatus.ACTIVE,
+ role: Constants.MembershipRole.POST,
+ });
+
+ const bulkPost = await CommunityPostModel.create({
+ domain: testDomain._id,
+ userId: adminUser.userId,
+ communityId: bulkCommunityId,
+ postId: `post-bulk-${Date.now()}`,
+ title: "Bulk doomed",
+ content: doc("bulk"),
+ category: "General",
+ deleted: false,
+ });
+
+ await togglePostReaction({
+ ctx: regularCtx,
+ communityId: bulkCommunityId,
+ postId: bulkPost.postId,
+ emoji: "🎉",
+ });
+
+ expect(
+ await CommunityReactionModel.countDocuments({
+ domain: testDomain._id,
+ postId: bulkPost.postId,
+ }),
+ ).toBe(1);
+
+ await deleteCommunityPosts(adminCtx, "community", bulkCommunityId);
+
+ expect(
+ await CommunityReactionModel.countDocuments({
+ domain: testDomain._id,
+ communityId: bulkCommunityId,
+ }),
+ ).toBe(0);
+ });
+});
diff --git a/apps/web/graphql/communities/helpers.ts b/apps/web/graphql/communities/helpers.ts
index 326341260..935cef305 100644
--- a/apps/web/graphql/communities/helpers.ts
+++ b/apps/web/graphql/communities/helpers.ts
@@ -1,8 +1,12 @@
import {
CommunityMedia,
CommunityPost,
+ CommunityReaction,
+ CommunityReactionEntityType,
CommunityReport,
CommunityReportType,
+ COMMUNITY_HEART_EMOJI,
+ compareCommunityReactionsStable,
Constants,
Membership,
TextEditorContent,
@@ -13,6 +17,7 @@ import CommunityCommentModel, {
import CommunityPostModel, {
InternalCommunityPost,
} from "@models/CommunityPost";
+import CommunityReactionModel from "@models/CommunityReaction";
import GQLContext from "@models/GQLContext";
import { deleteMedia } from "@/services/medialit";
import { responses } from "@/config/strings";
@@ -28,6 +33,7 @@ import {
extractTextFromTextEditorContent,
normalizeTextEditorContent,
} from "@courselit/utils";
+import UserModel from "@models/User";
export type PublicPost = Omit<
CommunityPost,
@@ -36,54 +42,296 @@ export type PublicPost = Omit<
userId: string;
};
+type ReactionRow = {
+ entityType: CommunityReactionEntityType;
+ entityId: string;
+ emoji: string;
+ userId: string;
+};
+
+/**
+ * Convert emoji → userIds map to CommunityReaction[] with reactor details.
+ */
+export async function formatReactions(
+ reactionsMap: Map,
+ userId: string,
+): Promise {
+ const entries: [string, string[]][] = [];
+ reactionsMap.forEach((value, key) => {
+ if (value.length > 0) {
+ entries.push([key, value]);
+ }
+ });
+
+ if (entries.length === 0) {
+ return [];
+ }
+
+ const allUserIds = Array.from(new Set(entries.flatMap(([, ids]) => ids)));
+
+ const users = await UserModel.find(
+ { userId: { $in: allUserIds } },
+ { userId: 1, name: 1, avatar: 1, _id: 0 },
+ ).lean();
+
+ const usersById = new Map(users.map((u: any) => [u.userId, u]));
+
+ const reactions: CommunityReaction[] = entries.map(([emoji, userIds]) => ({
+ emoji,
+ count: userIds.length,
+ hasReacted: userIds.includes(userId),
+ reactors: userIds.map((id) => {
+ const user = usersById.get(id);
+ return {
+ userId: id,
+ name: user?.name,
+ avatar: user?.avatar || ({} as any),
+ };
+ }),
+ }));
+
+ // Stable order: fixed picker order (not hasReacted / count) to avoid layout shift
+ reactions.sort(compareCommunityReactionsStable);
+
+ return reactions;
+}
+
+function heartLikesCount(reactions: CommunityReaction[]): number {
+ return reactions.find((r) => r.emoji === COMMUNITY_HEART_EMOJI)?.count ?? 0;
+}
+
+function hasHeartReaction(
+ reactions: CommunityReaction[],
+ userId: string,
+): boolean {
+ return (
+ reactions.find((r) => r.emoji === COMMUNITY_HEART_EMOJI)?.hasReacted ??
+ false
+ );
+}
+
+/**
+ * Load and format reactions for a single entity from the reactions collection.
+ */
+export async function loadReactionsForEntity({
+ domain,
+ entityType,
+ entityId,
+ userId,
+}: {
+ domain: mongoose.Types.ObjectId;
+ entityType: CommunityReactionEntityType;
+ entityId: string;
+ userId: string;
+}): Promise {
+ const rows = await CommunityReactionModel.find({
+ domain,
+ entityType,
+ entityId,
+ })
+ .select({ emoji: 1, userId: 1, _id: 0 })
+ .lean();
+
+ const map = new Map();
+ for (const row of rows) {
+ const list = map.get(row.emoji) || [];
+ list.push(row.userId);
+ map.set(row.emoji, list);
+ }
+ return formatReactions(map, userId);
+}
+
+/**
+ * Batch-load reactions. Returns Map keyed by `${entityType}:${entityId}`.
+ */
+export async function loadReactionsForEntities({
+ domain,
+ filter,
+ userId,
+}: {
+ domain: mongoose.Types.ObjectId;
+ filter: Record;
+ userId: string;
+}): Promise