feat(newsfeed): Improved UI flow for the course newsfeed (#791)

* feat(newsfeed): improved the newsfeed ui

* feat(newsfeed): updated the ui

* feat(newsfeed): some improvements

* feat(newsfeed): cleanup

* chore(newsfeed): added translations

* fix(newsfeed): fixing comments and reviews from greptile and coderabbit

* fix(newsfeed): fixing comments and reviews from greptile and coderabbit, improved replies to a reply

* fix(newsfeed): fixing reviews

* fix(ui): expose cancelLabel prop in comment-tree-input and pass localized text

---------

Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
Nwosu Emmanuel
2026-07-31 00:26:59 +01:00
committed by GitHub
co-authored by Rotimi Best
parent 88c4cf03b3
commit a1c4ee720f
40 changed files with 13639 additions and 280 deletions
@@ -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
);
}
};
@@ -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();
+6 -5
View File
@@ -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) => {
+40 -4
View File
@@ -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,