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, - ); - }} - /> +
+ { + 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) =>