From 386813813e1eb256ca4c333b383d66ed2f7771a6 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sat, 20 Jun 2026 14:22:51 +0000 Subject: [PATCH 1/6] fix: add likes field back to schemas and fix Map iteration for ES5 target --- apps/web/graphql/communities/helpers.ts | 187 ++++++++++++++---- apps/web/models/CommunityComment.ts | 90 ++------- apps/web/models/CommunityPost.ts | 2 + .../src/models/community-comment.ts | 9 +- .../orm-models/src/models/community-post.ts | 2 + 5 files changed, 176 insertions(+), 114 deletions(-) diff --git a/apps/web/graphql/communities/helpers.ts b/apps/web/graphql/communities/helpers.ts index 83e9d1623..47f8f981f 100644 --- a/apps/web/graphql/communities/helpers.ts +++ b/apps/web/graphql/communities/helpers.ts @@ -1,6 +1,7 @@ import { CommunityMedia, CommunityPost, + CommunityReaction, CommunityReport, CommunityReportType, Constants, @@ -25,6 +26,7 @@ import CommunityPostSubscriberModel, { } from "@models/CommunityPostSubscriber"; import { hasCommunityPermission } from "@ui-lib/utils"; import { normalizeTextEditorContent } from "@courselit/utils"; +import UserModel from "@models/User"; export type PublicPost = Omit< CommunityPost, @@ -33,54 +35,163 @@ export type PublicPost = Omit< userId: string; }; +const HEART_EMOJI = "❤️"; + +/** + * Get reactions from an entity that may have either `reactions` (Map) or legacy `likes` (string[]). + * Returns a Map. + */ +function getReactionsMap(entity: any): Map { + if (entity.reactions && typeof entity.reactions === "object") { + // New format: reactions is a Map or object + if (entity.reactions instanceof Map) { + if (entity.reactions.size > 0) { + return entity.reactions; + } + } else { + // Plain object from lean() / serialization + const entries = Object.entries(entity.reactions); + if (entries.length > 0) { + return new Map(entries); + } + } + } + // Legacy format: likes is a string[] + if (Array.isArray(entity.likes) && entity.likes.length > 0) { + return new Map([[HEART_EMOJI, [...entity.likes]]]); + } + return new Map(); +} + +/** + * Convert a Map to a CommunityReaction[] array with reactor details. + */ +export async function formatReactions( + reactionsMap: Map, + userId: string, +): Promise { + const reactions: CommunityReaction[] = []; + + const entries: [string, string[]][] = []; + reactionsMap.forEach((value, key) => { + entries.push([key, value]); + }); + + for (let i = 0; i < entries.length; i++) { + const [emoji, userIds] = entries[i]; + if (userIds.length === 0) continue; + + const reactors = await UserModel.find( + { userId: { $in: userIds } }, + { userId: 1, name: 1, avatar: 1, _id: 0 }, + ).lean(); + + reactions.push({ + emoji, + count: userIds.length, + hasReacted: userIds.includes(userId), + reactors: reactors.map((r: any) => ({ + userId: r.userId, + name: r.name, + avatar: r.avatar || {}, + })), + }); + } + + // Sort reactions: user's reactions first, then by count descending + reactions.sort((a, b) => { + if (a.hasReacted !== b.hasReacted) return a.hasReacted ? -1 : 1; + return b.count - a.count; + }); + + return reactions; +} + +/** + * Compute likesCount from reactions (sum of all reaction counts for backward compat). + */ +function computeLikesCount(reactionsMap: Map): number { + let count = 0; + reactionsMap.forEach(function (userIds: string[]) { + count += userIds.length; + }); + return count; +} + +/** + * Compute hasLiked from reactions (user is in any reaction). + */ +function computeHasLiked( + reactionsMap: Map, + userId: string, +): boolean { + let found = false; + reactionsMap.forEach(function (userIds: string[]) { + if (userIds.includes(userId)) found = true; + }); + return found; +} + export function normalizeCommunityPostContent( content: InternalCommunityPost["content"], ): TextEditorContent { return normalizeTextEditorContent(content); } -export const formatComment = (comment: any, userId: string) => ({ - communityId: comment.communityId, - postId: comment.postId, - userId: comment.userId, - commentId: comment.commentId, - content: comment.content, - hasLiked: comment.likes.includes(userId), - createdAt: comment.createdAt, - updatedAt: comment.updatedAt, - media: comment.media, - likesCount: comment.likes.length, - replies: comment.replies.map((reply) => ({ - replyId: reply.replyId, - userId: reply.userId, - content: reply.content, - media: reply.media, - parentReplyId: reply.parentReplyId, - createdAt: reply.createdAt, - updatedAt: reply.updatedAt, - likesCount: reply.likes.length, - hasLiked: reply.likes.includes(userId), - deleted: reply.deleted, - })), - deleted: comment.deleted, -}); +export const formatComment = (comment: any, userId: string) => { + const reactionsMap = getReactionsMap(comment); + return { + communityId: comment.communityId, + postId: comment.postId, + userId: comment.userId, + commentId: comment.commentId, + content: comment.content, + hasLiked: computeHasLiked(reactionsMap, userId), + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, + media: comment.media, + likesCount: computeLikesCount(reactionsMap), + reactions: [], // Populated by resolver with user details + replies: comment.replies.map((reply: any) => { + const replyReactionsMap = getReactionsMap(reply); + return { + replyId: reply.replyId, + userId: reply.userId, + content: reply.content, + media: reply.media, + parentReplyId: reply.parentReplyId, + createdAt: reply.createdAt, + updatedAt: reply.updatedAt, + likesCount: computeLikesCount(replyReactionsMap), + hasLiked: computeHasLiked(replyReactionsMap, userId), + reactions: [], // Populated by resolver + deleted: reply.deleted, + }; + }), + deleted: comment.deleted, + }; +}; export const formatPost = ( post: InternalCommunityPost, userId: string, -): PublicPost => ({ - communityId: post.communityId, - postId: post.postId, - title: post.title, - content: normalizeCommunityPostContent(post.content), - category: post.category, - media: post.media, - pinned: post.pinned, - likesCount: post.likes.length, - updatedAt: post.updatedAt, - hasLiked: post.likes.includes(userId), - userId: post.userId, -}); +): PublicPost => { + const reactionsMap = getReactionsMap(post); + return { + communityId: post.communityId, + postId: post.postId, + title: post.title, + content: normalizeCommunityPostContent(post.content), + category: post.category, + media: post.media, + pinned: post.pinned, + likesCount: computeLikesCount(reactionsMap), + updatedAt: post.updatedAt, + hasLiked: computeHasLiked(reactionsMap, userId), + reactions: [], // Populated by resolver with user details + userId: post.userId, + }; +}; export async function toggleContentVisibility( contentId: string, diff --git a/apps/web/models/CommunityComment.ts b/apps/web/models/CommunityComment.ts index cd7d89d8c..3459839d9 100644 --- a/apps/web/models/CommunityComment.ts +++ b/apps/web/models/CommunityComment.ts @@ -1,78 +1,18 @@ -import { generateUniqueId } from "@courselit/utils"; -import mongoose from "mongoose"; -import CommunityMediaSchema from "./CommunityMedia"; import { - CommunityComment, - CommunityCommentReply, -} from "@courselit/common-models"; + InternalCommunityComment, + InternalReply, + CommunityCommentSchema, +} from "@courselit/orm-models"; +import mongoose, { Model } from "mongoose"; -export interface InternalCommunityComment - extends Pick< - CommunityComment, - "communityId" | "postId" | "commentId" | "content" | "media" - > { - domain: mongoose.Types.ObjectId; - userId: string; - likes: string[]; - replies: InternalReply[]; - deleted: boolean; -} +const CommunityCommentModel = + (mongoose.models.CommunityComment as + | Model + | undefined) || + mongoose.model( + "CommunityComment", + CommunityCommentSchema, + ); -export interface InternalReply - extends Omit { - userId: string; - likes: string[]; -} - -const ReplySchema = new mongoose.Schema( - { - userId: { type: String, required: true }, - content: { type: String, required: true }, - media: [CommunityMediaSchema], - replyId: { type: String, required: true, default: generateUniqueId }, - parentReplyId: { type: String, default: null }, - likes: [String], - deleted: { type: Boolean, default: false }, - }, - { - timestamps: true, - }, -); - -const CommunityCommentSchema = new mongoose.Schema( - { - domain: { type: mongoose.Schema.Types.ObjectId, required: true }, - userId: { type: String, required: true }, - communityId: { type: String, required: true }, - postId: { type: String, required: true }, - commentId: { - type: String, - required: true, - unique: true, - default: generateUniqueId, - }, - content: { type: String, required: true }, - media: [CommunityMediaSchema], - likes: [String], - replies: [ReplySchema], - deleted: { type: Boolean, required: true, default: false }, - }, - { - timestamps: true, - }, -); - -CommunityCommentSchema.statics.paginatedFind = async function ( - filter, - options, -) { - const page = options.page || 1; - const limit = options.limit || 10; - const skip = (page - 1) * limit; - - const docs = await this.find(filter).skip(skip).limit(limit).exec(); - return docs; -}; - -export default mongoose.models.CommunityComment || - mongoose.model("CommunityComment", CommunityCommentSchema); +export type { InternalCommunityComment, InternalReply }; +export default CommunityCommentModel; diff --git a/apps/web/models/CommunityPost.ts b/apps/web/models/CommunityPost.ts index 155b91232..396b14c40 100644 --- a/apps/web/models/CommunityPost.ts +++ b/apps/web/models/CommunityPost.ts @@ -22,6 +22,7 @@ export interface InternalCommunityPost domain: mongoose.Types.ObjectId; userId: string; likes: string[]; + reactions: Map; createdAt: string; updatedAt: string; } @@ -43,6 +44,7 @@ const CommunityPostSchema = new mongoose.Schema( media: [CommunityMediaSchema], pinned: { type: Boolean, default: false }, likes: [String], + reactions: { type: Map, of: [String], default: {} }, deleted: { type: Boolean, default: false }, }, { diff --git a/packages/orm-models/src/models/community-comment.ts b/packages/orm-models/src/models/community-comment.ts index f980fb0e3..13b8cce12 100644 --- a/packages/orm-models/src/models/community-comment.ts +++ b/packages/orm-models/src/models/community-comment.ts @@ -14,14 +14,19 @@ export interface InternalCommunityComment domain: mongoose.Types.ObjectId; userId: string; likes: string[]; + reactions: Map; replies: InternalReply[]; deleted: boolean; } export interface InternalReply - extends Omit { + extends Omit< + CommunityCommentReply, + "likesCount" | "hasLiked" | "reactions" + > { userId: string; likes: string[]; + reactions: Map; } export const ReplySchema = new mongoose.Schema( @@ -32,6 +37,7 @@ export const ReplySchema = new mongoose.Schema( replyId: { type: String, required: true, default: generateUniqueId }, parentReplyId: { type: String, default: null }, likes: [String], + reactions: { type: Map, of: [String], default: {} }, deleted: { type: Boolean, default: false }, }, { @@ -55,6 +61,7 @@ export const CommunityCommentSchema = content: { type: String, required: true }, media: [CommunityMediaSchema], likes: [String], + reactions: { type: Map, of: [String], default: {} }, replies: [ReplySchema], deleted: { type: Boolean, required: true, default: false }, }, diff --git a/packages/orm-models/src/models/community-post.ts b/packages/orm-models/src/models/community-post.ts index bedc6d607..99c6d04ea 100644 --- a/packages/orm-models/src/models/community-post.ts +++ b/packages/orm-models/src/models/community-post.ts @@ -22,6 +22,7 @@ export interface InternalCommunityPost userId: string; content: TextEditorContent | string; likes: string[]; + reactions: Map; createdAt: string; updatedAt: string; } @@ -43,6 +44,7 @@ export const CommunityPostSchema = new mongoose.Schema( media: [CommunityMediaSchema], pinned: { type: Boolean, default: false }, likes: [String], + reactions: { type: Map, of: [String], default: {} }, deleted: { type: Boolean, default: false }, }, { From 188ef592a006bb7cd243103619d83f1e69bcb7c1 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sat, 20 Jun 2026 14:36:29 +0000 Subject: [PATCH 2/6] feat: community post/comment/reply reactions frontend (#809) --- .../components/community/comment-section.tsx | 360 +++++------------- apps/web/components/community/comment.tsx | 34 +- .../web/components/community/emoji-picker.tsx | 49 +++ apps/web/components/community/index.tsx | 62 ++- apps/web/components/community/post-card.tsx | 20 +- .../components/community/reactions-bar.tsx | 135 +++++++ apps/web/graphql/communities/helpers.ts | 5 +- apps/web/graphql/communities/logic.ts | 327 +++++++++++++++- apps/web/graphql/communities/mutation.ts | 86 +++++ apps/web/graphql/communities/types.ts | 49 ++- .../src/community-comment-reply.ts | 2 + .../common-models/src/community-comment.ts | 2 + packages/common-models/src/community-post.ts | 2 + .../common-models/src/community-reaction.ts | 12 + packages/common-models/src/index.ts | 1 + 15 files changed, 823 insertions(+), 323 deletions(-) create mode 100644 apps/web/components/community/emoji-picker.tsx create mode 100644 apps/web/components/community/reactions-bar.tsx create mode 100644 packages/common-models/src/community-reaction.ts diff --git a/apps/web/components/community/comment-section.tsx b/apps/web/components/community/comment-section.tsx index 7ec14b2b0..60137896a 100644 --- a/apps/web/components/community/comment-section.tsx +++ b/apps/web/components/community/comment-section.tsx @@ -53,6 +53,75 @@ const focusCommentTarget = (targetId: string) => { window.dispatchEvent(new Event("community-comment-target-change")); }; +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, @@ -149,49 +218,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} } } `; @@ -226,49 +253,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} } } `; @@ -323,49 +308,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} } } `; @@ -411,53 +354,11 @@ export default function CommentSection({ } }; - const handleCommentLike = async (commentId: string) => { + const handleCommentReact = async (commentId: string, emoji: string) => { 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} } } `; @@ -469,6 +370,7 @@ export default function CommentSection({ communityId, postId, commentId, + emoji, }, }) .setIsGraphQLEndpoint(true) @@ -487,52 +389,15 @@ export default function CommentSection({ } }; - const handleReplyLike = async (commentId: string, replyId: string) => { + const handleReplyReact = async ( + commentId: string, + emoji: string, + replyId: string, + ) => { 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} } } `; @@ -545,6 +410,7 @@ export default function CommentSection({ postId, commentId, replyId, + emoji, }, }) .setIsGraphQLEndpoint(true) @@ -569,49 +435,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} } } `; @@ -668,11 +492,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..ec44ac72b 100644 --- a/apps/web/components/community/comment.tsx +++ b/apps/web/components/community/comment.tsx @@ -3,7 +3,6 @@ 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, @@ -34,6 +33,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 +42,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 +57,7 @@ interface CommentProps { export function Comment({ communityId, comment, - onLike, + onReact, onReply, onDelete, membership, @@ -255,15 +255,21 @@ export function Comment({ )}

- + { + if (isCommunityComment(comment)) { + onReact(comment.commentId, emoji); + } else { + onReact( + comment.commentId, + emoji, + comment.replyId, + ); + } + }} + compact + /> + )} + + +
+ {COMMON_EMOJIS.map((emoji) => ( + + ))} +
+
+ + ); +} diff --git a/apps/web/components/community/index.tsx b/apps/web/components/community/index.tsx index 84bedbef3..58bc890b9 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,7 +270,11 @@ 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) => @@ -264,19 +282,30 @@ export function CommunityForum({ post.postId === postId ? { ...post, - likesCount: post.hasLiked - ? post.likesCount - 1 - : post.likesCount + 1, - hasLiked: !post.hasLiked, + hasLiked: true, } : 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 +314,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 +997,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..efa5a5825 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 { MessageSquare, 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); @@ -138,15 +139,10 @@ export default function CommunityPostCard({
- + onReact?.(post.postId, emoji)} + /> + ))} + { + onReact(emoji); + }} + > + + + {hoveredReaction && hoveredEmoji && tooltipPos && ( +
{ + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }} + onMouseLeave={handleMouseLeave} + > +
{hoveredEmoji}
+
+ {hoveredReaction.reactors.length > 0 + ? hoveredReaction.reactors + .map((r) => r.name || r.userId) + .join(", ") + : "..."} +
+
+ )} +
+ ); +} diff --git a/apps/web/graphql/communities/helpers.ts b/apps/web/graphql/communities/helpers.ts index 47f8f981f..1dd77a917 100644 --- a/apps/web/graphql/communities/helpers.ts +++ b/apps/web/graphql/communities/helpers.ts @@ -50,7 +50,10 @@ function getReactionsMap(entity: any): Map { } } else { // Plain object from lean() / serialization - const entries = Object.entries(entity.reactions); + const entries = Object.entries(entity.reactions) as [ + string, + string[], + ][]; if (entries.length > 0) { return new Map(entries); } diff --git a/apps/web/graphql/communities/logic.ts b/apps/web/graphql/communities/logic.ts index 0cab8c071..1cd0abeea 100644 --- a/apps/web/graphql/communities/logic.ts +++ b/apps/web/graphql/communities/logic.ts @@ -1505,6 +1505,88 @@ export async function updateMemberRole({ return targetMember; } +/** + * Get reactions for an entity (post, comment, or reply) with full reactor details. + */ +export async function getReactionsForEntity({ + entityType, + entity, + ctx, +}: { + entityType: "post" | "comment" | "reply"; + entity: any; + ctx: GQLContext; +}): Promise { + const { formatReactions } = await import("./helpers"); + let reactionsMap: Map; + + if (entityType === "reply") { + // For replies, the reactions are directly on the reply sub-document + reactionsMap = getReactionsMapFromEntity(entity); + } else if (entityType === "comment") { + reactionsMap = getReactionsMapFromEntity(entity); + } else { + reactionsMap = getReactionsMapFromEntity(entity); + } + + return formatReactions(reactionsMap, ctx.user?.userId || ""); +} + +function getReactionsMapFromEntity(entity: any): Map { + if (entity.reactions && typeof entity.reactions === "object") { + if (entity.reactions instanceof Map) { + return entity.reactions; + } + return new Map(Object.entries(entity.reactions)); + } + // Legacy: entity has likes array + if (Array.isArray(entity.likes)) { + return new Map([["❤️", [...entity.likes]]]); + } + return new Map(); +} + +function toggleReactionInMap( + reactionsMap: Map, + emoji: string, + userId: string, +): { added: boolean } { + const existing = reactionsMap.get(emoji) || []; + + if (existing.includes(userId)) { + // Remove user from this reaction + const filtered = existing.filter((id) => id !== userId); + if (filtered.length === 0) { + reactionsMap.delete(emoji); + } else { + reactionsMap.set(emoji, filtered); + } + return { added: false }; + } else { + // User might be in another reaction - remove from other reactions first + const keysToDelete: string[] = []; + const keysToUpdate: Map = new Map(); + reactionsMap.forEach(function (userIds: string[], key: string) { + if (key !== emoji && userIds.includes(userId)) { + const filtered = userIds.filter((id) => id !== userId); + if (filtered.length === 0) { + keysToDelete.push(key); + } else { + keysToUpdate.set(key, filtered); + } + } + }); + keysToDelete.forEach((key) => reactionsMap.delete(key)); + keysToUpdate.forEach(function (value, key) { + reactionsMap.set(key, value); + }); + // Add to this reaction + existing.push(userId); + reactionsMap.set(emoji, existing); + return { added: true }; + } +} + export async function togglePostLike({ ctx, communityId, @@ -1541,13 +1623,14 @@ export async function togglePostLike({ throw new Error(responses.action_not_allowed); } - let liked = false; - if (post.likes.includes(ctx.user.userId)) { - post.likes = post.likes.filter((id) => id !== ctx.user.userId); - } else { - post.likes.push(ctx.user.userId); - liked = true; - } + const reactionsMap = getReactionsMapFromEntity(post); + const { added: liked } = toggleReactionInMap( + reactionsMap, + "❤️", + ctx.user.userId, + ); + // Convert Map back to plain object for MongoDB + post.reactions = Object.fromEntries(reactionsMap); await post.save(); @@ -1567,6 +1650,70 @@ export async function togglePostLike({ return formatPost(post, ctx.user.userId); } +export async function togglePostReaction({ + ctx, + communityId, + postId, + emoji, +}: { + ctx: GQLContext; + communityId: string; + postId: string; + emoji: string; +}): Promise { + checkIfAuthenticated(ctx); + + const community = await CommunityModel.findOne( + getCommunityQuery(ctx, communityId), + ); + + if (!community) { + throw new Error(responses.item_not_found); + } + + const post = await CommunityPostModel.findOne({ + domain: ctx.subdomain._id, + communityId, + postId, + deleted: false, + }); + + if (!post) { + throw new Error(responses.item_not_found); + } + + const member = await getMembership(ctx, communityId); + + if (!member) { + throw new Error(responses.action_not_allowed); + } + + const reactionsMap = getReactionsMapFromEntity(post); + const { added: reacted } = toggleReactionInMap( + reactionsMap, + emoji, + ctx.user.userId, + ); + post.reactions = Object.fromEntries(reactionsMap); + + await post.save(); + + if (reacted && post.userId !== ctx.user.userId) { + await recordActivity({ + domain: ctx.subdomain._id, + userId: ctx.user.userId, + type: Constants.ActivityType.COMMUNITY_POST_LIKED, + entityId: post.postId, + metadata: { + communityId: community.communityId, + forUserIds: [post.userId], + }, + }); + } + + return formatPost(post, ctx.user.userId); +} + export async function togglePinned({ ctx, communityId, @@ -1829,12 +1976,10 @@ export async function toggleCommentLike({ } let liked = false; - if (comment.likes.includes(ctx.user.userId)) { - comment.likes = comment.likes.filter((id) => id !== ctx.user.userId); - } else { - comment.likes.push(ctx.user.userId); - liked = true; - } + const reactionsMap = getReactionsMapFromEntity(comment); + const result = toggleReactionInMap(reactionsMap, "❤️", ctx.user.userId); + liked = result.added; + comment.reactions = Object.fromEntries(reactionsMap); await comment.save(); @@ -1855,6 +2000,74 @@ export async function toggleCommentLike({ return formatComment(comment, ctx.user.userId); } +export async function toggleCommentReaction({ + ctx, + communityId, + postId, + commentId, + emoji, +}: { + ctx: GQLContext; + communityId: string; + postId: string; + commentId: string; + emoji: string; +}): Promise { + checkIfAuthenticated(ctx); + + const community = await CommunityModel.findOne( + getCommunityQuery(ctx, communityId), + ); + + if (!community) { + throw new Error(responses.item_not_found); + } + + const comment = await CommunityCommentModel.findOne({ + domain: ctx.subdomain._id, + communityId, + postId, + commentId, + deleted: false, + }); + + if (!comment) { + throw new Error(responses.item_not_found); + } + + const member = await getMembership(ctx, communityId); + + if (!member || !hasPermission(member, Constants.MembershipRole.COMMENT)) { + throw new Error(responses.action_not_allowed); + } + + const reactionsMap = getReactionsMapFromEntity(comment); + const { added: reacted } = toggleReactionInMap( + reactionsMap, + emoji, + ctx.user.userId, + ); + comment.reactions = Object.fromEntries(reactionsMap); + + await comment.save(); + + if (reacted && comment.userId !== ctx.user.userId) { + await recordActivity({ + domain: ctx.subdomain._id, + userId: ctx.user.userId, + type: Constants.ActivityType.COMMUNITY_COMMENT_LIKED, + entityId: comment.commentId, + metadata: { + communityId: community.communityId, + postId, + forUserIds: [comment.userId], + }, + }); + } + + return formatComment(comment, ctx.user.userId); +} + export async function toggleCommentReplyLike({ ctx, communityId, @@ -1903,12 +2116,10 @@ export async function toggleCommentReplyLike({ } let liked = false; - if (reply.likes.includes(ctx.user.userId)) { - reply.likes = reply.likes.filter((id) => id !== ctx.user.userId); - } else { - reply.likes.push(ctx.user.userId); - liked = true; - } + const reactionsMap = getReactionsMapFromEntity(reply); + const result = toggleReactionInMap(reactionsMap, "❤️", ctx.user.userId); + liked = result.added; + reply.reactions = Object.fromEntries(reactionsMap); await comment.save(); @@ -1931,6 +2142,84 @@ export async function toggleCommentReplyLike({ return formatComment(comment, ctx.user.userId); } +export async function toggleCommentReplyReaction({ + ctx, + communityId, + postId, + commentId, + replyId, + emoji, +}: { + ctx: GQLContext; + communityId: string; + postId: string; + commentId: string; + replyId: string; + emoji: string; +}): Promise { + checkIfAuthenticated(ctx); + + const community = await CommunityModel.findOne( + getCommunityQuery(ctx, communityId), + ); + + if (!community) { + throw new Error(responses.item_not_found); + } + + const comment = await CommunityCommentModel.findOne({ + domain: ctx.subdomain._id, + communityId, + postId, + commentId, + deleted: false, + }); + + if (!comment) { + throw new Error(responses.item_not_found); + } + + const member = await getMembership(ctx, communityId); + + if (!member || !hasPermission(member, Constants.MembershipRole.COMMENT)) { + throw new Error(responses.action_not_allowed); + } + + const reply = comment.replies.find((r) => r.replyId === replyId); + + if (!reply) { + throw new Error(responses.item_not_found); + } + + const reactionsMap = getReactionsMapFromEntity(reply); + const { added: reacted } = toggleReactionInMap( + reactionsMap, + emoji, + ctx.user.userId, + ); + reply.reactions = Object.fromEntries(reactionsMap); + + await comment.save(); + + if (reacted && reply.userId !== ctx.user.userId) { + await recordActivity({ + domain: ctx.subdomain._id, + userId: ctx.user.userId, + type: Constants.ActivityType.COMMUNITY_REPLY_LIKED, + entityId: reply.replyId, + metadata: { + communityId: community.communityId, + postId, + commentId: comment.commentId, + entityTargetId: comment.commentId, + forUserIds: [reply.userId], + }, + }); + } + + return formatComment(comment, ctx.user.userId); +} + export async function deleteComment({ ctx, communityId, diff --git a/apps/web/graphql/communities/mutation.ts b/apps/web/graphql/communities/mutation.ts index 3cc8ad981..10ae422ab 100644 --- a/apps/web/graphql/communities/mutation.ts +++ b/apps/web/graphql/communities/mutation.ts @@ -16,10 +16,13 @@ import { joinCommunity, updateMemberStatus, togglePostLike, + togglePostReaction, togglePinned, postComment, toggleCommentLike, + toggleCommentReaction, toggleCommentReplyLike, + toggleCommentReplyReaction, deleteComment, leaveCommunity, deleteCommunity, @@ -489,6 +492,89 @@ const mutations = { ctx, }), }, + togglePostReaction: { + type: types.communityPost, + args: { + communityId: { type: new GraphQLNonNull(GraphQLString) }, + postId: { type: new GraphQLNonNull(GraphQLString) }, + emoji: { type: new GraphQLNonNull(GraphQLString) }, + }, + resolve: async ( + _: any, + { + communityId, + postId, + emoji, + }: { communityId: string; postId: string; emoji: string }, + ctx: GQLContext, + ) => togglePostReaction({ communityId, postId, emoji, ctx }), + }, + toggleCommentReaction: { + type: types.communityComment, + args: { + communityId: { type: new GraphQLNonNull(GraphQLString) }, + postId: { type: new GraphQLNonNull(GraphQLString) }, + commentId: { type: new GraphQLNonNull(GraphQLString) }, + emoji: { type: new GraphQLNonNull(GraphQLString) }, + }, + resolve: async ( + _: any, + { + communityId, + postId, + commentId, + emoji, + }: { + communityId: string; + postId: string; + commentId: string; + emoji: string; + }, + ctx: GQLContext, + ) => + toggleCommentReaction({ + communityId, + postId, + commentId, + emoji, + ctx, + }), + }, + toggleCommentReplyReaction: { + type: types.communityComment, + args: { + communityId: { type: new GraphQLNonNull(GraphQLString) }, + postId: { type: new GraphQLNonNull(GraphQLString) }, + commentId: { type: new GraphQLNonNull(GraphQLString) }, + replyId: { type: new GraphQLNonNull(GraphQLString) }, + emoji: { type: new GraphQLNonNull(GraphQLString) }, + }, + resolve: async ( + _: any, + { + communityId, + postId, + commentId, + replyId, + emoji, + }: { + communityId: string; + postId: string; + commentId: string; + replyId: string; + emoji: string; + }, + ctx: GQLContext, + ) => + toggleCommentReplyReaction({ + communityId, + postId, + commentId, + replyId, + emoji, + ctx, + }), + }, }; export default mutations; diff --git a/apps/web/graphql/communities/types.ts b/apps/web/graphql/communities/types.ts index 149e144b0..2174233c9 100644 --- a/apps/web/graphql/communities/types.ts +++ b/apps/web/graphql/communities/types.ts @@ -10,13 +10,13 @@ import { } from "graphql"; import { GraphQLJSONObject } from "graphql-type-json"; import mediaTypes from "../media/types"; -import { Constants } from "@courselit/common-models"; +import { Constants, CommunityPost } from "@courselit/common-models"; import userTypes from "../users/types"; import { getUser } from "../users/logic"; import GQLContext from "@models/GQLContext"; import paymentPlansTypes from "../paymentplans/types"; import { getPlans } from "../paymentplans/logic"; -import { getCommentsCount } from "./logic"; +import { getCommentsCount, getReactionsForEntity } from "./logic"; const communityReportContentType = new GraphQLEnumType({ name: "CommunityReportContentType", @@ -94,6 +94,23 @@ const feedCommunity = new GraphQLObjectType({ }, }); +const communityReaction = new GraphQLObjectType({ + name: "CommunityReaction", + fields: { + emoji: { type: new GraphQLNonNull(GraphQLString) }, + count: { type: new GraphQLNonNull(GraphQLInt) }, + hasReacted: { type: new GraphQLNonNull(GraphQLBoolean) }, + reactors: { + type: new GraphQLList(userTypes.userType), + resolve: (reaction, _, ctx: GQLContext, __) => { + return reaction.reactors.map((reactor: any) => + getUser(reactor.userId, ctx), + ); + }, + }, + }, +}); + const communityPost = new GraphQLObjectType({ name: "CommunityPost", fields: { @@ -117,6 +134,15 @@ const communityPost = new GraphQLObjectType({ }, updatedAt: { type: new GraphQLNonNull(GraphQLString) }, hasLiked: { type: new GraphQLNonNull(GraphQLBoolean) }, + reactions: { + type: new GraphQLList(communityReaction), + resolve: async (post: CommunityPost, _, ctx: GQLContext, __) => + getReactionsForEntity({ + entityType: "post", + entity: post, + ctx, + }), + }, community: { type: feedCommunity }, }, }); @@ -153,6 +179,15 @@ const communityCommentReply = new GraphQLObjectType({ likesCount: { type: new GraphQLNonNull(GraphQLInt) }, updatedAt: { type: new GraphQLNonNull(GraphQLString) }, hasLiked: { type: new GraphQLNonNull(GraphQLBoolean) }, + reactions: { + type: new GraphQLList(communityReaction), + resolve: async (reply, _, ctx: GQLContext, __) => + getReactionsForEntity({ + entityType: "reply", + entity: reply, + ctx, + }), + }, deleted: { type: new GraphQLNonNull(GraphQLBoolean) }, }, }); @@ -173,6 +208,15 @@ const communityComment = new GraphQLObjectType({ likesCount: { type: new GraphQLNonNull(GraphQLInt) }, updatedAt: { type: new GraphQLNonNull(GraphQLString) }, hasLiked: { type: new GraphQLNonNull(GraphQLBoolean) }, + reactions: { + type: new GraphQLList(communityReaction), + resolve: async (comment, _, ctx: GQLContext, __) => + getReactionsForEntity({ + entityType: "comment", + entity: comment, + ctx, + }), + }, replies: { type: new GraphQLList(communityCommentReply) }, deleted: { type: new GraphQLNonNull(GraphQLBoolean) }, }, @@ -215,6 +259,7 @@ const types = { communityMemberStatus, communityPostInputMedia, communityComment, + communityReaction, communityReportContentType, communityReport, communityReportStatusType, diff --git a/packages/common-models/src/community-comment-reply.ts b/packages/common-models/src/community-comment-reply.ts index 29ff17271..762c70873 100644 --- a/packages/common-models/src/community-comment-reply.ts +++ b/packages/common-models/src/community-comment-reply.ts @@ -1,3 +1,4 @@ +import { CommunityReaction } from "./community-reaction"; import User from "./user"; export interface CommunityCommentReply { @@ -14,5 +15,6 @@ export interface CommunityCommentReply { updatedAt: string; likesCount: number; hasLiked: boolean; + reactions: CommunityReaction[]; deleted: boolean; } diff --git a/packages/common-models/src/community-comment.ts b/packages/common-models/src/community-comment.ts index 381d04045..9bacf6951 100644 --- a/packages/common-models/src/community-comment.ts +++ b/packages/common-models/src/community-comment.ts @@ -1,5 +1,6 @@ import { CommunityCommentReply } from "./community-comment-reply"; import { CommunityMedia } from "./community-media"; +import { CommunityReaction } from "./community-reaction"; import User from "./user"; export interface CommunityComment { @@ -13,6 +14,7 @@ export interface CommunityComment { updatedAt: string; createdAt: string; hasLiked: boolean; + reactions: CommunityReaction[]; replies: CommunityCommentReply[]; deleted: boolean; } diff --git a/packages/common-models/src/community-post.ts b/packages/common-models/src/community-post.ts index f1a3a74af..f590655f5 100644 --- a/packages/common-models/src/community-post.ts +++ b/packages/common-models/src/community-post.ts @@ -1,5 +1,6 @@ import { CommunityMedia } from "./community-media"; import { TextEditorContent } from "./text-editor-content"; +import { CommunityReaction } from "./community-reaction"; import User from "./user"; export interface CommunityPost { @@ -16,5 +17,6 @@ export interface CommunityPost { updatedAt: string; createdAt: string; hasLiked: boolean; + reactions: CommunityReaction[]; deleted: boolean; } diff --git a/packages/common-models/src/community-reaction.ts b/packages/common-models/src/community-reaction.ts new file mode 100644 index 000000000..9efc03a26 --- /dev/null +++ b/packages/common-models/src/community-reaction.ts @@ -0,0 +1,12 @@ +import { Media } from "./media"; + +export interface CommunityReaction { + emoji: string; + count: number; + hasReacted: boolean; + reactors: { + userId: string; + name?: string; + avatar: Media; + }[]; +} diff --git a/packages/common-models/src/index.ts b/packages/common-models/src/index.ts index dfa46d776..fe58c0d62 100644 --- a/packages/common-models/src/index.ts +++ b/packages/common-models/src/index.ts @@ -73,3 +73,4 @@ export * from "./email-event-action"; export * from "./login-provider"; export * from "./features"; export type { ScormContent } from "./scorm-content"; +export type { CommunityReaction } from "./community-reaction"; From fcaaa5317719fc2055e3bd0476bcefe2cbb7f2dc Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Wed, 1 Jul 2026 03:34:02 +0000 Subject: [PATCH 3/6] unified reactions/reply bar --- ...-06-26_00-00-convert-likes-to-reactions.js | 201 ++++++++++++++++++ .../[id]/[postId]/community-post-page.tsx | 83 +++++++- apps/web/app/api/graph/route.ts | 8 +- apps/web/components/community/comment.tsx | 15 +- .../web/components/community/emoji-picker.tsx | 6 +- apps/web/components/community/index.tsx | 11 - apps/web/components/community/post-card.tsx | 22 +- .../components/community/reactions-bar.tsx | 88 ++++++-- apps/web/graphql/communities/helpers.ts | 9 +- apps/web/graphql/communities/logic.ts | 22 +- apps/web/next.config.js | 1 + apps/web/proxy.ts | 4 +- .../get-notification-message-and-href.ts | 4 +- 13 files changed, 378 insertions(+), 96 deletions(-) create mode 100644 apps/web/.migrations/20-06-26_00-00-convert-likes-to-reactions.js 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..6a4b2e816 --- /dev/null +++ b/apps/web/.migrations/20-06-26_00-00-convert-likes-to-reactions.js @@ -0,0 +1,201 @@ +/** + * Converts existing `likes: string[]` fields to `reactions: { "❤️": string[] }` format + * on CommunityPost and CommunityComment documents. + * + * This migration is idempotent - safe to re-run. + * + * 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"); +} + +(async () => { + try { + await mongoose.connect(DB_CONNECTION_STRING); + + const db = mongoose.connection.db; + if (!db) { + throw new Error("Could not connect to database"); + } + + const BATCH_SIZE = 500; + const HEART_EMOJI = "❤️"; + + // --- Migrate CommunityPost documents --- + console.log("Migrating CommunityPost documents..."); + let postCursor = db + .collection("communityposts") + .find({ + $or: [ + { + likes: { $exists: true }, + $expr: { $gt: [{ $size: "$likes" }, 0] }, + }, + ], + }) + .batchSize(BATCH_SIZE); + + let postBatch = []; + let postCount = 0; + + while (await postCursor.hasNext()) { + const doc = await postCursor.next(); + if (!doc) continue; + + const update = { + $unset: { likes: "" }, + $set: { + reactions: { [HEART_EMOJI]: doc.likes || [] }, + }, + }; + + postBatch.push({ + updateOne: { + filter: { _id: doc._id }, + update, + }, + }); + + if (postBatch.length >= BATCH_SIZE) { + const result = await db + .collection("communityposts") + .bulkWrite(postBatch); + postCount += result.modifiedCount; + console.log( + ` Processed ${postCount} CommunityPost documents...`, + ); + postBatch = []; + } + } + + if (postBatch.length > 0) { + const result = await db + .collection("communityposts") + .bulkWrite(postBatch); + postCount += result.modifiedCount; + } + + console.log(`✅ Migrated ${postCount} CommunityPost documents.`); + + // --- Migrate CommunityComment documents --- + console.log("Migrating CommunityComment documents..."); + let commentCursor = db + .collection("communitycomments") + .find({ + $or: [ + { + likes: { $exists: true }, + $expr: { $gt: [{ $size: "$likes" }, 0] }, + }, + ], + }) + .batchSize(BATCH_SIZE); + + let commentBatch = []; + let commentCount = 0; + + while (await commentCursor.hasNext()) { + const doc = await commentCursor.next(); + if (!doc) continue; + + const update = { + $unset: { likes: "" }, + $set: { + reactions: { [HEART_EMOJI]: doc.likes || [] }, + }, + }; + + // Also migrate nested reply likes + if (doc.replies && Array.isArray(doc.replies)) { + const hasReplyLikes = doc.replies.some( + (reply) => + reply.likes && + Array.isArray(reply.likes) && + reply.likes.length > 0, + ); + + if (hasReplyLikes) { + update.$set["replies"] = doc.replies.map((reply) => { + if ( + reply.likes && + Array.isArray(reply.likes) && + reply.likes.length > 0 + ) { + const { likes, ...rest } = reply; + return { + ...rest, + reactions: { [HEART_EMOJI]: likes }, + }; + } + return reply; + }); + } + } + + commentBatch.push({ + updateOne: { + filter: { _id: doc._id }, + update, + }, + }); + + if (commentBatch.length >= BATCH_SIZE) { + const result = await db + .collection("communitycomments") + .bulkWrite(commentBatch); + commentCount += result.modifiedCount; + console.log( + ` Processed ${commentCount} CommunityComment documents...`, + ); + commentBatch = []; + } + } + + if (commentBatch.length > 0) { + const result = await db + .collection("communitycomments") + .bulkWrite(commentBatch); + commentCount += result.modifiedCount; + } + + console.log(`✅ Migrated ${commentCount} CommunityComment documents.`); + + // --- Also migrate docs that have both likes and no reactions --- + console.log( + "Setting default reactions on documents without reactions...", + ); + const postResult = await db + .collection("communityposts") + .updateMany( + { reactions: { $exists: false } }, + { $set: { reactions: {} } }, + ); + console.log( + ` Set default reactions on ${postResult.modifiedCount} CommunityPost documents.`, + ); + + const commentResult = db + .collection("communitycomments") + .updateMany( + { reactions: { $exists: false } }, + { $set: { reactions: {} } }, + ); + const commentResult2 = await commentResult; + console.log( + ` Set default reactions on ${commentResult2.modifiedCount} CommunityComment documents.`, + ); + + console.log("✅ Migration complete!"); + } catch (err) { + 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 2df497a12..5fb816cb2 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 @@ -210,6 +224,58 @@ export default function CommunityPostPage({ } }; + const handleReact = async (targetPostId: string, emoji: string) => { + const query = ` + 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 + } + } + } + } + } + `; + + try { + const fetch = new FetchBuilder() + .setUrl(`${address.backend}/api/graph`) + .setPayload({ + query, + variables: { communityId, postId: targetPostId, emoji }, + }) + .setIsGraphQLEndpoint(true) + .build(); + 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", + }); + } + }; + const uploadAttachments = useCallback( async (media: MediaItem[]) => { for (const i in media) { @@ -644,15 +710,12 @@ export default function CommunityPostPage({
)}
- + + handleReact(currentPost.postId, emoji) + } + />
diff --git a/apps/web/components/community/emoji-picker.tsx b/apps/web/components/community/emoji-picker.tsx index 6794d5722..6dc0d9dc4 100644 --- a/apps/web/components/community/emoji-picker.tsx +++ b/apps/web/components/community/emoji-picker.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Popover, @@ -15,8 +16,10 @@ interface EmojiPickerProps { } export function EmojiPicker({ onEmojiSelect, children }: EmojiPickerProps) { + const [open, setOpen] = useState(false); + return ( - + {children || ( - + onReact?.(post.postId, emoji)} + compact + 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 index 5cb627f48..060e43e96 100644 --- a/apps/web/components/community/reactions-bar.tsx +++ b/apps/web/components/community/reactions-bar.tsx @@ -2,6 +2,8 @@ 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 { @@ -12,12 +14,27 @@ interface ReactionsBarProps { * Defaults to false (full layout for post cards). */ compact?: boolean; + /** + * 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, compact = false, + onReply, + showReplyButton = false, + repliesCount, }: ReactionsBarProps) { const [hoveredEmoji, setHoveredEmoji] = useState(null); const [tooltipPos, setTooltipPos] = useState<{ @@ -60,22 +77,43 @@ export function ReactionsBar({ }; }, []); + const activeReactions = reactions.filter((r) => r.count > 0); const hoveredReaction = reactions.find((r) => r.emoji === hoveredEmoji); return ( -
- {reactions - .filter((r) => r.count > 0) - .map((reaction) => ( + <> +
+ {/* Emoji picker — always first */} + { + onReact(emoji); + }} + > + + + + {/* Active reaction pills — wrap as they accumulate */} + {activeReactions.map((reaction) => ( ))} - { - onReact(emoji); - }} - > - - + {showReplyButton && ( + + )} +
+ {hoveredReaction && hoveredEmoji && tooltipPos && (
)} -
+ ); } diff --git a/apps/web/graphql/communities/helpers.ts b/apps/web/graphql/communities/helpers.ts index 1dd77a917..2d544b195 100644 --- a/apps/web/graphql/communities/helpers.ts +++ b/apps/web/graphql/communities/helpers.ts @@ -154,7 +154,7 @@ export const formatComment = (comment: any, userId: string) => { updatedAt: comment.updatedAt, media: comment.media, likesCount: computeLikesCount(reactionsMap), - reactions: [], // Populated by resolver with user details + reactions: Object.fromEntries(reactionsMap) as unknown as CommunityReaction[], replies: comment.replies.map((reply: any) => { const replyReactionsMap = getReactionsMap(reply); return { @@ -167,7 +167,7 @@ export const formatComment = (comment: any, userId: string) => { updatedAt: reply.updatedAt, likesCount: computeLikesCount(replyReactionsMap), hasLiked: computeHasLiked(replyReactionsMap, userId), - reactions: [], // Populated by resolver + reactions: Object.fromEntries(replyReactionsMap) as unknown as CommunityReaction[], deleted: reply.deleted, }; }), @@ -191,7 +191,10 @@ export const formatPost = ( likesCount: computeLikesCount(reactionsMap), updatedAt: post.updatedAt, hasLiked: computeHasLiked(reactionsMap, userId), - reactions: [], // Populated by resolver with user details + // Store raw reactions map so the GraphQL field resolver can + // access reactor details. Field resolver calls + // getReactionsForEntity which reads entity.reactions. + reactions: Object.fromEntries(reactionsMap) as unknown as CommunityReaction[], userId: post.userId, }; }; diff --git a/apps/web/graphql/communities/logic.ts b/apps/web/graphql/communities/logic.ts index 1cd0abeea..51b3a6b04 100644 --- a/apps/web/graphql/communities/logic.ts +++ b/apps/web/graphql/communities/logic.ts @@ -1554,7 +1554,7 @@ function toggleReactionInMap( const existing = reactionsMap.get(emoji) || []; if (existing.includes(userId)) { - // Remove user from this reaction + // Remove user from this reaction (toggle off) const filtered = existing.filter((id) => id !== userId); if (filtered.length === 0) { reactionsMap.delete(emoji); @@ -1563,24 +1563,7 @@ function toggleReactionInMap( } return { added: false }; } else { - // User might be in another reaction - remove from other reactions first - const keysToDelete: string[] = []; - const keysToUpdate: Map = new Map(); - reactionsMap.forEach(function (userIds: string[], key: string) { - if (key !== emoji && userIds.includes(userId)) { - const filtered = userIds.filter((id) => id !== userId); - if (filtered.length === 0) { - keysToDelete.push(key); - } else { - keysToUpdate.set(key, filtered); - } - } - }); - keysToDelete.forEach((key) => reactionsMap.delete(key)); - keysToUpdate.forEach(function (value, key) { - reactionsMap.set(key, value); - }); - // Add to this reaction + // Add user to this reaction — allow multiple different emoji reactions per user existing.push(userId); reactionsMap.set(emoji, existing); return { added: true }; @@ -1707,6 +1690,7 @@ export async function togglePostReaction({ metadata: { communityId: community.communityId, forUserIds: [post.userId], + emoji, }, }); } diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 139057198..57ef89a3a 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -31,6 +31,7 @@ const nextConfig = { "jsonwebtoken", ], experimental: {}, + allowedDevOrigins: ["clcomp.taile2f1.ts.net"], }; module.exports = nextConfig; diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index 6a034470f..2370250e5 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -1,6 +1,6 @@ import { NextResponse, type NextRequest } from "next/server"; import { getBackendAddress } from "@/app/actions"; -import { auth } from "./auth"; +import { getAuth } from "./auth"; import { COURSE_VIEWER_CURRENT_URL_HEADER } from "./lib/course-viewer-session-params"; export async function proxy(request: NextRequest) { @@ -77,7 +77,7 @@ export async function proxy(request: NextRequest) { } if (request.nextUrl.pathname.startsWith("/dashboard")) { - const session = await auth.api.getSession({ + const session = await getAuth(backend).api.getSession({ headers: requestHeaders, }); if (!session) { diff --git a/packages/common-logic/src/utils/get-notification-message-and-href.ts b/packages/common-logic/src/utils/get-notification-message-and-href.ts index 52b001fbf..9712999b0 100644 --- a/packages/common-logic/src/utils/get-notification-message-and-href.ts +++ b/packages/common-logic/src/utils/get-notification-message-and-href.ts @@ -195,8 +195,10 @@ export async function getNotificationMessageAndHref({ return { message: "", href: "" }; } + const emoji = (metadata?.emoji as string) || "👍"; + return { - message: `${actorName} liked your post '${truncate(post.title, 20).trim()}' in ${community.name}`, + message: `${actorName} reacted ${emoji} to your post '${truncate(post.title, 20).trim()}' in ${community.name}`, href: toHref( `/dashboard/community/${community.communityId}/${post.postId}`, hrefPrefix, From dddd1233d93dce2e2425b3570b7c5a882cf56eab Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Wed, 1 Jul 2026 03:34:15 +0000 Subject: [PATCH 4/6] prettier fix helpers.ts --- apps/web/graphql/communities/helpers.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/web/graphql/communities/helpers.ts b/apps/web/graphql/communities/helpers.ts index 2d544b195..f06fb95e0 100644 --- a/apps/web/graphql/communities/helpers.ts +++ b/apps/web/graphql/communities/helpers.ts @@ -154,7 +154,9 @@ export const formatComment = (comment: any, userId: string) => { updatedAt: comment.updatedAt, media: comment.media, likesCount: computeLikesCount(reactionsMap), - reactions: Object.fromEntries(reactionsMap) as unknown as CommunityReaction[], + reactions: Object.fromEntries( + reactionsMap, + ) as unknown as CommunityReaction[], replies: comment.replies.map((reply: any) => { const replyReactionsMap = getReactionsMap(reply); return { @@ -167,7 +169,9 @@ export const formatComment = (comment: any, userId: string) => { updatedAt: reply.updatedAt, likesCount: computeLikesCount(replyReactionsMap), hasLiked: computeHasLiked(replyReactionsMap, userId), - reactions: Object.fromEntries(replyReactionsMap) as unknown as CommunityReaction[], + reactions: Object.fromEntries( + replyReactionsMap, + ) as unknown as CommunityReaction[], deleted: reply.deleted, }; }), @@ -194,7 +198,9 @@ export const formatPost = ( // Store raw reactions map so the GraphQL field resolver can // access reactor details. Field resolver calls // getReactionsForEntity which reads entity.reactions. - reactions: Object.fromEntries(reactionsMap) as unknown as CommunityReaction[], + reactions: Object.fromEntries( + reactionsMap, + ) as unknown as CommunityReaction[], userId: post.userId, }; }; From 57b3da2f4306c3cf9ab4a52a154853bfefd8fee4 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Tue, 7 Jul 2026 06:20:07 +0000 Subject: [PATCH 5/6] Fix #535: Implement webhook signature verification for Stripe, Razorpay & LemonSqueezy - Stripe: Uses stripe.webhooks.constructEvent() with webhookSecret to verify signatures and validate checkout.session.completed / invoice.paid events. - Razorpay: Uses HMAC-SHA256 with timingSafeEqual for signature validation on order.paid and subscription.charged events. - LemonSqueezy: Uses HMAC-SHA256 with timingSafeEqual for x-signature validation on order_created (one_time), subscription_payment_success, and subscription_resumed events. - Webhook route: Reads raw body via req.text() and forwards signature headers to paymentMethod.verify(). - Payment interface: Updated verify() signature to accept rawBody and headers. - DB schema: Added stripeWebhookSecret to SiteInfo/SettingsSchema. - Fixed TypeScript type errors with Uint8Array wrapping for crypto.timingSafeEqual calls. - Cleaned pre-existing unused import lint errors in comment.tsx and post-card.tsx. --- apps/web/app/api/payment/webhook/route.ts | 11 +++- apps/web/components/community/comment.tsx | 8 +-- apps/web/components/community/post-card.tsx | 2 +- apps/web/next-env.d.ts | 2 +- apps/web/payments-new/lemonsqueezy-payment.ts | 52 +++++++++++++------ apps/web/payments-new/payment.ts | 6 ++- apps/web/payments-new/razorpay-payment.ts | 36 ++++++++++++- apps/web/payments-new/stripe-payment.ts | 24 ++++++++- packages/orm-models/src/models/site-info.ts | 1 + 9 files changed, 110 insertions(+), 32 deletions(-) diff --git a/apps/web/app/api/payment/webhook/route.ts b/apps/web/app/api/payment/webhook/route.ts index 4d1285fc9..4111bf885 100644 --- a/apps/web/app/api/payment/webhook/route.ts +++ b/apps/web/app/api/payment/webhook/route.ts @@ -17,7 +17,8 @@ import { activateMembership } from "../helpers"; export async function POST(req: NextRequest) { try { - const body = await req.json(); + const rawBody = await req.text(); + const body = JSON.parse(rawBody); const domainName = req.headers.get("domain"); const domain = await getDomain(domainName); @@ -33,7 +34,13 @@ export async function POST(req: NextRequest) { return Response.json({ message: "Payment method not found" }); } - if (!(await paymentMethod.verify(body))) { + const headers: Record = { + "stripe-signature": req.headers.get("stripe-signature"), + "x-razorpay-signature": req.headers.get("x-razorpay-signature"), + "x-signature": req.headers.get("x-signature"), + }; + + if (!(await paymentMethod.verify(body, rawBody, headers))) { return Response.json({ message: "Payment not verified" }); } diff --git a/apps/web/components/community/comment.tsx b/apps/web/components/community/comment.tsx index 8db607ad2..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 { - MessageSquare, - MoreVertical, - FlagTriangleRight, - Trash, - Reply, -} from "lucide-react"; +import { MoreVertical, FlagTriangleRight, Trash } from "lucide-react"; import { CommunityComment, CommunityCommentReply, diff --git a/apps/web/components/community/post-card.tsx b/apps/web/components/community/post-card.tsx index a31361db1..d449592ee 100644 --- a/apps/web/components/community/post-card.tsx +++ b/apps/web/components/community/post-card.tsx @@ -15,7 +15,7 @@ import { } from "@courselit/page-blocks"; import { CommunityMedia, CommunityPost } from "@courselit/common-models"; import { capitalize, truncate } from "@courselit/utils"; -import { MessageSquare, Pin } from "lucide-react"; +import { Pin } from "lucide-react"; import Link from "next/link"; import { useContext } from "react"; import { ThemeContext } from "@components/contexts"; diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 9edff1c7c..c4b7818fb 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/payments-new/lemonsqueezy-payment.ts b/apps/web/payments-new/lemonsqueezy-payment.ts index 1dfe26233..2e61898e5 100644 --- a/apps/web/payments-new/lemonsqueezy-payment.ts +++ b/apps/web/payments-new/lemonsqueezy-payment.ts @@ -1,5 +1,6 @@ import Payment, { InitiateProps } from "./payment"; import { responses } from "../config/strings"; +import crypto from "crypto"; import { Constants, PaymentPlan, @@ -20,7 +21,7 @@ export default class LemonSqueezyPayment implements Payment { public siteinfo: SiteInfo; public name: string; private apiKey: string; - // private webhookSecret: string; + private webhookSecret: string | undefined; constructor(siteinfo: SiteInfo) { this.siteinfo = siteinfo; @@ -45,7 +46,7 @@ export default class LemonSqueezyPayment implements Payment { } this.apiKey = this.siteinfo.lemonsqueezyKey; - // this.webhookSecret = this.siteinfo.lemonsqueezyWebhookSecret; + this.webhookSecret = this.siteinfo.lemonsqueezyWebhookSecret; return this; } @@ -134,8 +135,38 @@ export default class LemonSqueezyPayment implements Payment { return store.attributes.currency.toLowerCase(); } - async verify(event: any) { - // if (!this.verifyWebhookSignature(event)) return false; + async verify( + event: any, + rawBody: string, + headers: Record, + ) { + const signature = headers["x-signature"]; + if (!signature || !this.webhookSecret) { + return false; + } + + const digest = crypto + .createHmac("sha256", this.webhookSecret) + .update(rawBody) + .digest("hex"); + const receivedHash = signature.startsWith("sha256=") + ? signature.slice(7) + : signature; + try { + const expected = Buffer.from(digest); + const received = Buffer.from(receivedHash); + if ( + expected.length !== received.length || + !crypto.timingSafeEqual( + new Uint8Array(expected), + new Uint8Array(received), + ) + ) { + return false; + } + } catch { + return false; + } const eventType = event.meta.event_name; const attributes = event.data.attributes; @@ -267,17 +298,4 @@ export default class LemonSqueezyPayment implements Payment { const { data } = await response.json(); return data; } - - // private verifyWebhookSignature(event: any) { - // const signature = event.meta.signature; - // const payload = JSON.stringify(event); - - // const hmac = crypto.createHmac("sha256", this.webhookSecret); - // const digest = hmac.update(payload).digest("hex"); - - // return crypto.timingSafeEqual( - // Buffer.from(signature), - // Buffer.from(digest) - // ); - // } } diff --git a/apps/web/payments-new/payment.ts b/apps/web/payments-new/payment.ts index ba9d60e3d..bb92f3a22 100644 --- a/apps/web/payments-new/payment.ts +++ b/apps/web/payments-new/payment.ts @@ -19,7 +19,11 @@ interface Metadata { export default interface Payment { setup: () => void; initiate: (obj: InitiateProps) => void; - verify: (event: any) => Promise; + verify: ( + event: any, + rawBody: string, + headers: Record, + ) => Promise; getPaymentIdentifier: (event: any) => unknown; getMetadata: (event: any) => Record; getName: () => string; diff --git a/apps/web/payments-new/razorpay-payment.ts b/apps/web/payments-new/razorpay-payment.ts index 7d93addca..7be36a7b2 100644 --- a/apps/web/payments-new/razorpay-payment.ts +++ b/apps/web/payments-new/razorpay-payment.ts @@ -3,6 +3,7 @@ import Payment, { InitiateProps } from "./payment"; import { responses } from "@config/strings"; import Razorpay from "razorpay"; import { getUnitAmount } from "./helpers"; +import crypto from "crypto"; const { payment_invalid_settings: paymentInvalidSettings, @@ -13,6 +14,7 @@ export default class RazorpayPayment implements Payment { public siteinfo: SiteInfo; public name: string; public razorpay: any; + private webhookSecret: string | undefined; constructor(siteinfo: SiteInfo) { this.siteinfo = siteinfo; @@ -33,6 +35,8 @@ export default class RazorpayPayment implements Payment { key_secret: this.siteinfo.razorpaySecret, }); + this.webhookSecret = this.siteinfo.razorpayWebhookSecret; + return this; } @@ -45,10 +49,40 @@ export default class RazorpayPayment implements Payment { return order.id; } - async verify(event) { + async verify( + event: any, + rawBody: string, + headers: Record, + ) { if (!event) { return false; } + + const signature = headers["x-razorpay-signature"]; + if (!signature || !this.webhookSecret) { + return false; + } + + const expectedSignature = crypto + .createHmac("sha256", this.webhookSecret) + .update(rawBody) + .digest("hex"); + try { + const expected = Buffer.from(expectedSignature); + const received = Buffer.from(signature); + if ( + expected.length !== received.length || + !crypto.timingSafeEqual( + new Uint8Array(expected), + new Uint8Array(received), + ) + ) { + return false; + } + } catch { + return false; + } + if ( event.event === "order.paid" && event.payload.order.entity.notes.membershipId diff --git a/apps/web/payments-new/stripe-payment.ts b/apps/web/payments-new/stripe-payment.ts index 9f8aa3112..000ccb9c7 100644 --- a/apps/web/payments-new/stripe-payment.ts +++ b/apps/web/payments-new/stripe-payment.ts @@ -18,6 +18,7 @@ export default class StripePayment implements Payment { public siteinfo: SiteInfo; public name: string; public stripe: any; + private webhookSecret: string | undefined; constructor(siteinfo: SiteInfo) { this.siteinfo = siteinfo; @@ -37,6 +38,8 @@ export default class StripePayment implements Payment { typescript: true, }); + this.webhookSecret = this.siteinfo.stripeWebhookSecret; + return this; } @@ -77,10 +80,27 @@ export default class StripePayment implements Payment { return this.siteinfo.currencyISOCode!; } - async verify(event: Stripe.Event) { - if (!event) { + async verify( + _event: any, + rawBody: string, + headers: Record, + ) { + const signature = headers["stripe-signature"]; + if (!signature || !this.webhookSecret) { + return false; + } + + let event: Stripe.Event; + try { + event = this.stripe.webhooks.constructEvent( + rawBody, + signature, + this.webhookSecret, + ); + } catch { return false; } + if ( event.type === "checkout.session.completed" && (event.data.object as any).payment_status === "paid" diff --git a/packages/orm-models/src/models/site-info.ts b/packages/orm-models/src/models/site-info.ts index 3430532a8..188198fd0 100644 --- a/packages/orm-models/src/models/site-info.ts +++ b/packages/orm-models/src/models/site-info.ts @@ -9,6 +9,7 @@ export const SettingsSchema = new mongoose.Schema({ currencyISOCode: { type: String, maxlength: 3 }, paymentMethod: { type: String, enum: Constants.paymentMethods }, stripeKey: { type: String }, + stripeWebhookSecret: { type: String }, codeInjectionHead: { type: String }, codeInjectionBody: { type: String }, stripeSecret: { type: String }, From 136c04ccd711e0d6276970648eeb8ba4f358eefe Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Tue, 7 Jul 2026 13:43:54 +0000 Subject: [PATCH 6/6] Revert "Fix #535: Implement webhook signature verification for Stripe, Razorpay & LemonSqueezy" This reverts commit 57b3da2f4306c3cf9ab4a52a154853bfefd8fee4. --- apps/web/app/api/payment/webhook/route.ts | 11 +--- apps/web/components/community/comment.tsx | 8 ++- apps/web/components/community/post-card.tsx | 2 +- apps/web/next-env.d.ts | 2 +- apps/web/payments-new/lemonsqueezy-payment.ts | 52 ++++++------------- apps/web/payments-new/payment.ts | 6 +-- apps/web/payments-new/razorpay-payment.ts | 36 +------------ apps/web/payments-new/stripe-payment.ts | 24 +-------- packages/orm-models/src/models/site-info.ts | 1 - 9 files changed, 32 insertions(+), 110 deletions(-) diff --git a/apps/web/app/api/payment/webhook/route.ts b/apps/web/app/api/payment/webhook/route.ts index 4111bf885..4d1285fc9 100644 --- a/apps/web/app/api/payment/webhook/route.ts +++ b/apps/web/app/api/payment/webhook/route.ts @@ -17,8 +17,7 @@ import { activateMembership } from "../helpers"; export async function POST(req: NextRequest) { try { - const rawBody = await req.text(); - const body = JSON.parse(rawBody); + const body = await req.json(); const domainName = req.headers.get("domain"); const domain = await getDomain(domainName); @@ -34,13 +33,7 @@ export async function POST(req: NextRequest) { return Response.json({ message: "Payment method not found" }); } - const headers: Record = { - "stripe-signature": req.headers.get("stripe-signature"), - "x-razorpay-signature": req.headers.get("x-razorpay-signature"), - "x-signature": req.headers.get("x-signature"), - }; - - if (!(await paymentMethod.verify(body, rawBody, headers))) { + if (!(await paymentMethod.verify(body))) { return Response.json({ message: "Payment not verified" }); } diff --git a/apps/web/components/community/comment.tsx b/apps/web/components/community/comment.tsx index b691f7165..8db607ad2 100644 --- a/apps/web/components/community/comment.tsx +++ b/apps/web/components/community/comment.tsx @@ -2,7 +2,13 @@ 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 { MoreVertical, FlagTriangleRight, Trash } from "lucide-react"; +import { + MessageSquare, + MoreVertical, + FlagTriangleRight, + Trash, + Reply, +} from "lucide-react"; import { CommunityComment, CommunityCommentReply, diff --git a/apps/web/components/community/post-card.tsx b/apps/web/components/community/post-card.tsx index d449592ee..a31361db1 100644 --- a/apps/web/components/community/post-card.tsx +++ b/apps/web/components/community/post-card.tsx @@ -15,7 +15,7 @@ import { } from "@courselit/page-blocks"; import { CommunityMedia, CommunityPost } from "@courselit/common-models"; import { capitalize, truncate } from "@courselit/utils"; -import { Pin } from "lucide-react"; +import { MessageSquare, Pin } from "lucide-react"; import Link from "next/link"; import { useContext } from "react"; import { ThemeContext } from "@components/contexts"; diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index c4b7818fb..9edff1c7c 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/payments-new/lemonsqueezy-payment.ts b/apps/web/payments-new/lemonsqueezy-payment.ts index 2e61898e5..1dfe26233 100644 --- a/apps/web/payments-new/lemonsqueezy-payment.ts +++ b/apps/web/payments-new/lemonsqueezy-payment.ts @@ -1,6 +1,5 @@ import Payment, { InitiateProps } from "./payment"; import { responses } from "../config/strings"; -import crypto from "crypto"; import { Constants, PaymentPlan, @@ -21,7 +20,7 @@ export default class LemonSqueezyPayment implements Payment { public siteinfo: SiteInfo; public name: string; private apiKey: string; - private webhookSecret: string | undefined; + // private webhookSecret: string; constructor(siteinfo: SiteInfo) { this.siteinfo = siteinfo; @@ -46,7 +45,7 @@ export default class LemonSqueezyPayment implements Payment { } this.apiKey = this.siteinfo.lemonsqueezyKey; - this.webhookSecret = this.siteinfo.lemonsqueezyWebhookSecret; + // this.webhookSecret = this.siteinfo.lemonsqueezyWebhookSecret; return this; } @@ -135,38 +134,8 @@ export default class LemonSqueezyPayment implements Payment { return store.attributes.currency.toLowerCase(); } - async verify( - event: any, - rawBody: string, - headers: Record, - ) { - const signature = headers["x-signature"]; - if (!signature || !this.webhookSecret) { - return false; - } - - const digest = crypto - .createHmac("sha256", this.webhookSecret) - .update(rawBody) - .digest("hex"); - const receivedHash = signature.startsWith("sha256=") - ? signature.slice(7) - : signature; - try { - const expected = Buffer.from(digest); - const received = Buffer.from(receivedHash); - if ( - expected.length !== received.length || - !crypto.timingSafeEqual( - new Uint8Array(expected), - new Uint8Array(received), - ) - ) { - return false; - } - } catch { - return false; - } + async verify(event: any) { + // if (!this.verifyWebhookSignature(event)) return false; const eventType = event.meta.event_name; const attributes = event.data.attributes; @@ -298,4 +267,17 @@ export default class LemonSqueezyPayment implements Payment { const { data } = await response.json(); return data; } + + // private verifyWebhookSignature(event: any) { + // const signature = event.meta.signature; + // const payload = JSON.stringify(event); + + // const hmac = crypto.createHmac("sha256", this.webhookSecret); + // const digest = hmac.update(payload).digest("hex"); + + // return crypto.timingSafeEqual( + // Buffer.from(signature), + // Buffer.from(digest) + // ); + // } } diff --git a/apps/web/payments-new/payment.ts b/apps/web/payments-new/payment.ts index bb92f3a22..ba9d60e3d 100644 --- a/apps/web/payments-new/payment.ts +++ b/apps/web/payments-new/payment.ts @@ -19,11 +19,7 @@ interface Metadata { export default interface Payment { setup: () => void; initiate: (obj: InitiateProps) => void; - verify: ( - event: any, - rawBody: string, - headers: Record, - ) => Promise; + verify: (event: any) => Promise; getPaymentIdentifier: (event: any) => unknown; getMetadata: (event: any) => Record; getName: () => string; diff --git a/apps/web/payments-new/razorpay-payment.ts b/apps/web/payments-new/razorpay-payment.ts index 7be36a7b2..7d93addca 100644 --- a/apps/web/payments-new/razorpay-payment.ts +++ b/apps/web/payments-new/razorpay-payment.ts @@ -3,7 +3,6 @@ import Payment, { InitiateProps } from "./payment"; import { responses } from "@config/strings"; import Razorpay from "razorpay"; import { getUnitAmount } from "./helpers"; -import crypto from "crypto"; const { payment_invalid_settings: paymentInvalidSettings, @@ -14,7 +13,6 @@ export default class RazorpayPayment implements Payment { public siteinfo: SiteInfo; public name: string; public razorpay: any; - private webhookSecret: string | undefined; constructor(siteinfo: SiteInfo) { this.siteinfo = siteinfo; @@ -35,8 +33,6 @@ export default class RazorpayPayment implements Payment { key_secret: this.siteinfo.razorpaySecret, }); - this.webhookSecret = this.siteinfo.razorpayWebhookSecret; - return this; } @@ -49,40 +45,10 @@ export default class RazorpayPayment implements Payment { return order.id; } - async verify( - event: any, - rawBody: string, - headers: Record, - ) { + async verify(event) { if (!event) { return false; } - - const signature = headers["x-razorpay-signature"]; - if (!signature || !this.webhookSecret) { - return false; - } - - const expectedSignature = crypto - .createHmac("sha256", this.webhookSecret) - .update(rawBody) - .digest("hex"); - try { - const expected = Buffer.from(expectedSignature); - const received = Buffer.from(signature); - if ( - expected.length !== received.length || - !crypto.timingSafeEqual( - new Uint8Array(expected), - new Uint8Array(received), - ) - ) { - return false; - } - } catch { - return false; - } - if ( event.event === "order.paid" && event.payload.order.entity.notes.membershipId diff --git a/apps/web/payments-new/stripe-payment.ts b/apps/web/payments-new/stripe-payment.ts index 000ccb9c7..9f8aa3112 100644 --- a/apps/web/payments-new/stripe-payment.ts +++ b/apps/web/payments-new/stripe-payment.ts @@ -18,7 +18,6 @@ export default class StripePayment implements Payment { public siteinfo: SiteInfo; public name: string; public stripe: any; - private webhookSecret: string | undefined; constructor(siteinfo: SiteInfo) { this.siteinfo = siteinfo; @@ -38,8 +37,6 @@ export default class StripePayment implements Payment { typescript: true, }); - this.webhookSecret = this.siteinfo.stripeWebhookSecret; - return this; } @@ -80,27 +77,10 @@ export default class StripePayment implements Payment { return this.siteinfo.currencyISOCode!; } - async verify( - _event: any, - rawBody: string, - headers: Record, - ) { - const signature = headers["stripe-signature"]; - if (!signature || !this.webhookSecret) { - return false; - } - - let event: Stripe.Event; - try { - event = this.stripe.webhooks.constructEvent( - rawBody, - signature, - this.webhookSecret, - ); - } catch { + async verify(event: Stripe.Event) { + if (!event) { return false; } - if ( event.type === "checkout.session.completed" && (event.data.object as any).payment_status === "paid" diff --git a/packages/orm-models/src/models/site-info.ts b/packages/orm-models/src/models/site-info.ts index 188198fd0..3430532a8 100644 --- a/packages/orm-models/src/models/site-info.ts +++ b/packages/orm-models/src/models/site-info.ts @@ -9,7 +9,6 @@ export const SettingsSchema = new mongoose.Schema({ currencyISOCode: { type: String, maxlength: 3 }, paymentMethod: { type: String, enum: Constants.paymentMethods }, stripeKey: { type: String }, - stripeWebhookSecret: { type: String }, codeInjectionHead: { type: String }, codeInjectionBody: { type: String }, stripeSecret: { type: String },