diff --git a/apps/api/src/middlewares/newsfeed-comment-author-only.ts b/apps/api/src/middlewares/newsfeed-comment-author-only.ts new file mode 100644 index 00000000..ddcc2076 --- /dev/null +++ b/apps/api/src/middlewares/newsfeed-comment-author-only.ts @@ -0,0 +1,76 @@ +import { Context, Next } from 'hono'; +import { getGroupMemberIdByCourseAndProfile } from '@cio/db/queries/group'; + +import { ErrorCodes } from '@api/utils/errors'; +import { getNewsfeedCommentAuthorAndCourse } from '@cio/db/queries/newsfeed'; + +/** + * Middleware to check if user is strictly the comment author + * Requires authMiddleware to be applied first + * Expects commentId in route params as 'commentId' + */ +export const newsfeedCommentAuthorOnlyMiddleware = async (c: Context, next: Next) => { + try { + const user = c.get('user'); + if (!user) { + return c.json( + { + success: false, + error: 'Unauthorized', + code: 'UNAUTHORIZED' + }, + 401 + ); + } + + const commentId = c.req.param('commentId'); + if (!commentId) { + return c.json( + { + success: false, + error: 'Comment ID is required', + code: 'COMMENT_ID_REQUIRED' + }, + 400 + ); + } + + const commentInfo = await getNewsfeedCommentAuthorAndCourse(Number(commentId)); + if (!commentInfo || !commentInfo.courseId) { + return c.json( + { + success: false, + error: 'Comment not found', + code: ErrorCodes.COMMENT_NOT_FOUND + }, + 404 + ); + } + + const userGroupMemberId = await getGroupMemberIdByCourseAndProfile(commentInfo.courseId, user.id); + const isAuthor = Boolean(userGroupMemberId && commentInfo.authorId && commentInfo.authorId === userGroupMemberId); + + if (!isAuthor) { + return c.json( + { + success: false, + error: 'Only the author of this comment can edit it', + code: ErrorCodes.ORG_TEAM_NOT_AUTHORIZED + }, + 403 + ); + } + + await next(); + } catch (error) { + console.error('Error in newsfeedCommentAuthorOnlyMiddleware:', error); + return c.json( + { + success: false, + error: 'Failed to verify comment authorization', + code: 'COMMENT_AUTH_CHECK_FAILED' + }, + 500 + ); + } +}; diff --git a/apps/api/src/middlewares/newsfeed-comment-author-or-team.ts b/apps/api/src/middlewares/newsfeed-comment-author-or-team.ts index c494b5c4..e40bd511 100644 --- a/apps/api/src/middlewares/newsfeed-comment-author-or-team.ts +++ b/apps/api/src/middlewares/newsfeed-comment-author-or-team.ts @@ -49,7 +49,7 @@ export const newsfeedCommentAuthorOrTeamMiddleware = async (c: Context, next: Ne // Check if user is the author of the comment const userGroupMemberId = await getGroupMemberIdByCourseAndProfile(commentInfo.courseId, user.id); - const isAuthor = commentInfo.authorId === userGroupMemberId; + const isAuthor = Boolean(userGroupMemberId && commentInfo.authorId && commentInfo.authorId === userGroupMemberId); if (isAuthor) { await next(); diff --git a/apps/api/src/routes/course/newsfeed.ts b/apps/api/src/routes/course/newsfeed.ts index 9f63f14d..03079f37 100644 --- a/apps/api/src/routes/course/newsfeed.ts +++ b/apps/api/src/routes/course/newsfeed.ts @@ -28,6 +28,7 @@ import { courseMemberMiddleware } from '@api/middlewares/course-member'; import { courseTeamMemberMiddleware } from '@api/middlewares/course-team-member'; import { getGroupMemberIdByCourseAndProfile } from '@cio/db/queries/group'; import { handleError } from '@api/utils/errors'; +import { newsfeedCommentAuthorOnlyMiddleware } from '@api/middlewares/newsfeed-comment-author-only'; import { newsfeedCommentAuthorOrTeamMiddleware } from '@api/middlewares/newsfeed-comment-author-or-team'; import { zValidator } from '@hono/zod-validator'; @@ -130,9 +131,9 @@ export const newsfeedRouter = new Hono() async (c) => { try { const { feedId } = c.req.valid('param'); - const { cursor, limit = 5 } = c.req.valid('query'); + const { parentId, cursor, limit = 5 } = c.req.valid('query'); - const result = await getNewsfeedCommentsService(feedId, { cursor, limit }); + const result = await getNewsfeedCommentsService(feedId, { parentId, cursor, limit }); return c.json({ success: true, data: result }, 200); } catch (error) { @@ -151,14 +152,14 @@ export const newsfeedRouter = new Hono() const user = c.get('user')!; const courseId = c.req.param('courseId')!; const { feedId } = c.req.valid('param'); - const { content } = c.req.valid('json'); + const { content, parentId } = c.req.valid('json'); const groupMemberId = await getGroupMemberIdByCourseAndProfile(courseId, user.id); if (!groupMemberId) { return c.json({ success: false, error: 'User is not a member of this course' }, 403); } - const comment = await createNewsfeedCommentService(feedId, groupMemberId, content); + const comment = await createNewsfeedCommentService(feedId, groupMemberId, content, parentId); return c.json({ success: true, data: comment }, 201); } catch (error) { @@ -169,7 +170,7 @@ export const newsfeedRouter = new Hono() .put( '/comment/:commentId', authMiddleware, - newsfeedCommentAuthorOrTeamMiddleware, + newsfeedCommentAuthorOnlyMiddleware, zValidator('param', ZNewsfeedCommentGetParam), zValidator('json', ZNewsfeedCommentUpdate), async (c) => { diff --git a/apps/api/src/services/newsfeed/newsfeed.ts b/apps/api/src/services/newsfeed/newsfeed.ts index 2dfeaf34..f3a553e6 100644 --- a/apps/api/src/services/newsfeed/newsfeed.ts +++ b/apps/api/src/services/newsfeed/newsfeed.ts @@ -11,6 +11,7 @@ import { getNewsfeedByCourseId, getNewsfeedByCourseIdPaginated, getNewsfeedById, + getNewsfeedCommentById, getNewsfeedCommentsByFeedId, getNewsfeedCommentsByFeedIdPaginated, getNewsfeedForEmail, @@ -231,10 +232,13 @@ export async function getNewsfeedComments(feedId: string) { /** * Gets paginated comments for a newsfeed item * @param feedId Newsfeed ID - * @param options Pagination options (cursor, limit) + * @param options Pagination options (parentId, cursor, limit) * @returns Paginated comments with metadata */ -export async function getNewsfeedCommentsService(feedId: string, options: { cursor?: string; limit: number }) { +export async function getNewsfeedCommentsService( + feedId: string, + options: { parentId?: number; cursor?: string; limit: number } +) { try { const feed = await getNewsfeedById(feedId); if (!feed) { @@ -260,16 +264,44 @@ export async function getNewsfeedCommentsService(feedId: string, options: { curs * @param feedId Newsfeed ID * @param authorId Group member ID (author) * @param content Comment content + * @param parentId Optional parent comment ID for replies * @returns Created comment */ -export async function createNewsfeedCommentService(feedId: string, authorId: string, content: string) { +export async function createNewsfeedCommentService( + feedId: string, + authorId: string, + content: string, + parentId?: number +) { try { + // Validate parentId when provided + if (parentId !== undefined) { + const parentComment = await getNewsfeedCommentById(parentId); + + if (!parentComment) { + throw new AppError('Parent comment not found', ErrorCodes.COMMENT_NOT_FOUND, 404); + } + + if (parentComment.courseNewsfeedId !== feedId) { + throw new AppError('Parent comment does not belong to this feed', ErrorCodes.VALIDATION_ERROR, 400); + } + + if (parentComment.parentId !== null) { + throw new AppError( + 'Cannot reply to a reply — only top-level comments can be replied to', + ErrorCodes.VALIDATION_ERROR, + 400 + ); + } + } + const sanitizedContent = sanitizeHtml(content); const commentData: TNewCourseNewsfeedComment = { courseNewsfeedId: feedId, authorId, - content: sanitizedContent + content: sanitizedContent, + parentId: parentId ?? null }; const comment = await createNewsfeedComment(commentData); @@ -281,6 +313,10 @@ export async function createNewsfeedCommentService(feedId: string, authorId: str return comment; } catch (error) { + if (error instanceof AppError) { + throw error; + } + throw new AppError( error instanceof Error ? error.message : 'Failed to create newsfeed comment', ErrorCodes.INTERNAL_ERROR, diff --git a/apps/dashboard/src/lib/features/course/api/newsfeed.svelte.ts b/apps/dashboard/src/lib/features/course/api/newsfeed.svelte.ts index 8f970680..e446288f 100644 --- a/apps/dashboard/src/lib/features/course/api/newsfeed.svelte.ts +++ b/apps/dashboard/src/lib/features/course/api/newsfeed.svelte.ts @@ -44,6 +44,8 @@ export class NewsfeedApi extends BaseApiWithErrors { commentsByFeedId = $state>({}); + repliesByParentId = $state>({}); + isNewFeedModalOpen = $state(false); /** @@ -75,7 +77,7 @@ export class NewsfeedApi extends BaseApiWithErrors { if (response.data) { this.feeds = Array.isArray(response.data) ? response.data : response.data.items || []; for (const feed of this.feeds) { - this.initComments(feed.id, 0); + this.initComments(feed.id, feed.commentCount || 0); } } }, @@ -356,6 +358,91 @@ export class NewsfeedApi extends BaseApiWithErrors { }); } + /** + * Fetch replies for a specific parent comment + */ + async getReplies(courseId: string, feedId: string, parentId: number, limit = 5) { + if (!this.repliesByParentId[parentId]) { + this.repliesByParentId[parentId] = { + items: [], + totalCount: 0, + hasMore: false, + isLoading: true, + cursor: null + }; + } else { + this.repliesByParentId[parentId].isLoading = true; + } + + await this.execute({ + requestFn: () => + classroomio.course[':courseId'].newsfeed[':feedId'].comments.$get({ + param: { courseId, feedId }, + query: { parentId: String(parentId), limit: String(limit) } + }), + logContext: 'fetching newsfeed comment replies', + onSuccess: (response) => { + if (response.data) { + this.repliesByParentId[parentId] = { + items: response.data.items, + totalCount: response.data.totalCount, + hasMore: response.data.hasMore, + isLoading: false, + cursor: response.data.nextCursor + }; + } + }, + onError: (result) => { + if (this.repliesByParentId[parentId]) { + this.repliesByParentId[parentId].isLoading = false; + } + if (typeof result === 'string') { + snackbar.error('Failed to fetch replies'); + } + } + }); + } + + /** + * Load more replies for a specific parent comment (uses cursor) + */ + async loadMoreReplies(courseId: string, feedId: string, parentId: number, limit = 5) { + const replyState = this.repliesByParentId[parentId]; + if (!replyState || !replyState.hasMore || replyState.isLoading || !replyState.cursor) { + return; + } + + replyState.isLoading = true; + + await this.execute({ + requestFn: () => + classroomio.course[':courseId'].newsfeed[':feedId'].comments.$get({ + param: { courseId, feedId }, + query: { parentId: String(parentId), cursor: replyState.cursor!, limit: String(limit) } + }), + logContext: 'loading more newsfeed replies', + onSuccess: (response) => { + if (response.data) { + this.repliesByParentId[parentId] = { + items: [...replyState.items, ...response.data.items], + totalCount: response.data.totalCount, + hasMore: response.data.hasMore, + isLoading: false, + cursor: response.data.nextCursor + }; + } + }, + onError: (result) => { + if (this.repliesByParentId[parentId]) { + this.repliesByParentId[parentId].isLoading = false; + } + if (typeof result === 'string') { + snackbar.error('Failed to load more replies'); + } + } + }); + } + /** * Load more comments (uses cursor) */ @@ -395,16 +482,16 @@ export class NewsfeedApi extends BaseApiWithErrors { } /** - * Creates a newsfeed comment + * Creates a newsfeed comment or reply */ async createComment( courseId: string, feedId: string, content: string, - author: { id: string; username: string; fullname: string; avatarUrl: string } + author: { id: string; username: string; fullname: string; avatarUrl: string }, + parentId?: number ) { - const result = ZNewsfeedCommentCreate.safeParse({ courseNewsfeedId: feedId, content }); - console.log('result', result); + const result = ZNewsfeedCommentCreate.safeParse({ courseNewsfeedId: feedId, content, parentId }); if (!result.success) { this.errors = mapZodErrorsToTranslations(result.error, 'newsfeed'); return; @@ -421,22 +508,53 @@ export class NewsfeedApi extends BaseApiWithErrors { if (response.data) { snackbar.success('Comment added successfully'); - // Add comment to local state - const commentState = this.commentsByFeedId[feedId]; - if (commentState) { - const newComment = { - ...response.data, - // Map author data to the expected flat structure - authorProfileId: author.id, - authorFullname: author.fullname, - authorUsername: author.username, - authorAvatarUrl: author.avatarUrl - }; - this.commentsByFeedId[feedId] = { - ...commentState, - items: [newComment, ...commentState.items], - totalCount: commentState.totalCount + 1 - }; + const newComment = { + ...response.data, + authorProfileId: author.id, + authorFullname: author.fullname, + authorUsername: author.username, + authorAvatarUrl: author.avatarUrl, + replyCount: 0 + }; + + if (parentId) { + const repliesState = this.repliesByParentId[parentId]; + if (repliesState) { + this.repliesByParentId[parentId] = { + ...repliesState, + items: [...repliesState.items, newComment], + totalCount: repliesState.totalCount + 1 + }; + } else { + this.repliesByParentId[parentId] = { + items: [newComment], + totalCount: 1, + hasMore: false, + isLoading: false, + cursor: null + }; + } + + // Increment parent reply count in local top-level items + const commentState = this.commentsByFeedId[feedId]; + if (commentState) { + const updatedItems = commentState.items.map((item) => { + if (item.id === parentId) { + return { ...item, replyCount: (item.replyCount || 0) + 1 }; + } + return item; + }); + this.commentsByFeedId[feedId] = { ...commentState, items: updatedItems }; + } + } else { + const commentState = this.commentsByFeedId[feedId]; + if (commentState) { + this.commentsByFeedId[feedId] = { + ...commentState, + items: [newComment, ...commentState.items], + totalCount: commentState.totalCount + 1 + }; + } } } }, @@ -451,7 +569,7 @@ export class NewsfeedApi extends BaseApiWithErrors { /** * Updates a newsfeed comment */ - async updateComment(courseId: string, commentId: string, content: string) { + async updateComment(courseId: string, feedId: string, commentId: string, content: string, parentId?: number) { const result = ZNewsfeedCommentUpdate.safeParse({ content }); if (!result.success) { this.errors = mapZodErrorsToTranslations(result.error, 'newsfeed'); @@ -465,6 +583,34 @@ export class NewsfeedApi extends BaseApiWithErrors { json: result.data }), logContext: 'updating newsfeed comment', + onSuccess: (response) => { + if (response.data) { + snackbar.success('Comment updated successfully'); + + const numericId = Number(commentId); + if (parentId && this.repliesByParentId[parentId]) { + const replyState = this.repliesByParentId[parentId]; + const updatedItems = replyState.items.map((item) => { + if (item.id === numericId) { + return { ...item, content: response.data?.content ?? content }; + } + return item; + }); + this.repliesByParentId[parentId] = { ...replyState, items: updatedItems }; + } else { + const commentState = this.commentsByFeedId[feedId]; + if (commentState) { + const updatedItems = commentState.items.map((item) => { + if (item.id === numericId) { + return { ...item, content: response.data?.content ?? content }; + } + return item; + }); + this.commentsByFeedId[feedId] = { ...commentState, items: updatedItems }; + } + } + } + }, onError: (result) => { if (typeof result === 'string') { snackbar.error('Failed to update comment'); @@ -476,7 +622,7 @@ export class NewsfeedApi extends BaseApiWithErrors { /** * Deletes a newsfeed comment */ - async deleteComment(courseId: string, feedId: string, commentId: string) { + async deleteComment(courseId: string, feedId: string, commentId: string, parentId?: number) { await this.execute({ requestFn: () => classroomio.course[':courseId'].newsfeed.comment[':commentId'].$delete({ @@ -487,14 +633,37 @@ export class NewsfeedApi extends BaseApiWithErrors { if (response.data) { snackbar.success('Comment deleted successfully'); - // Remove comment from local state - const commentState = this.commentsByFeedId[feedId]; - if (commentState) { - this.commentsByFeedId[feedId] = { - ...commentState, - items: commentState.items.filter((item) => String(item.id) !== commentId), - totalCount: Math.max(0, commentState.totalCount - 1) + const numericId = Number(commentId); + if (parentId && this.repliesByParentId[parentId]) { + const replyState = this.repliesByParentId[parentId]; + this.repliesByParentId[parentId] = { + ...replyState, + items: replyState.items.filter((item) => String(item.id) !== commentId), + totalCount: Math.max(0, replyState.totalCount - 1) }; + + const commentState = this.commentsByFeedId[feedId]; + if (commentState) { + const updatedItems = commentState.items.map((item) => { + if (item.id === parentId) { + return { ...item, replyCount: Math.max(0, (item.replyCount || 1) - 1) }; + } + return item; + }); + this.commentsByFeedId[feedId] = { ...commentState, items: updatedItems }; + } + } else { + if (this.repliesByParentId[numericId]) { + delete this.repliesByParentId[numericId]; + } + const commentState = this.commentsByFeedId[feedId]; + if (commentState) { + this.commentsByFeedId[feedId] = { + ...commentState, + items: commentState.items.filter((item) => String(item.id) !== commentId), + totalCount: Math.max(0, commentState.totalCount - 1) + }; + } } } }, diff --git a/apps/dashboard/src/lib/features/course/components/newsfeed/card/card.svelte b/apps/dashboard/src/lib/features/course/components/newsfeed/card/card.svelte index 968b2a2f..7487d662 100644 --- a/apps/dashboard/src/lib/features/course/components/newsfeed/card/card.svelte +++ b/apps/dashboard/src/lib/features/course/components/newsfeed/card/card.svelte @@ -80,13 +80,13 @@
(isDeleteFeedModal = true)} /> -
+
{ - await addNewComment(content, feed.id); + onAddComment={async (content, parentId) => { + await addNewComment(content, feed.id, parentId); }} - onDeleteComment={(commentId) => { - if (courseId) deleteComment(feed.id, String(commentId)); + onDeleteComment={(commentId, parentId) => { + if (courseId) deleteComment(feed.id, String(commentId), parentId); }} /> diff --git a/apps/dashboard/src/lib/features/course/components/newsfeed/card/comments.svelte b/apps/dashboard/src/lib/features/course/components/newsfeed/card/comments.svelte index 414c7ea8..9f2f7172 100644 --- a/apps/dashboard/src/lib/features/course/components/newsfeed/card/comments.svelte +++ b/apps/dashboard/src/lib/features/course/components/newsfeed/card/comments.svelte @@ -1,18 +1,15 @@ -
-
+
+ {#if totalCount > shownCount} + {/if} - {#if shownCount > 1} - - {/if} -
- -
+ {#if comments} {#each comments.items.slice(0, shownCount) as commentItem (commentItem.id)} {#if commentItem.content} -
- -
- {$t('course.navItem.news_feed.user_avatar_alt')} -
- -
-

{commentItem.authorFullname || ''}

-

{calDateDiff(commentItem.createdAt)}

-
-

{commentItem.content}

-
-
+ {@const commentIdNum = Number(commentItem.id)} + {@const repliesState = newsfeedApi.repliesByParentId[commentIdNum]} - {#if commentItem.authorProfileId === $profile.id || $isOrgAdmin} - - - - onDeleteComment(commentItem.id)}> - {$t('course.navItem.news_feed.comments.delete')} - - - - {/if} -
+ +
+
+ handleStartEdit(commentIdNum, commentItem.content)} + canDelete={commentItem.authorProfileId === $profile.id || $isOrgAdmin} + onDelete={() => onDeleteComment(commentItem.id)} + /> +
+ {#if editingCommentId === commentIdNum} +
+