feat: move community supabase request to api layer (#612)

* feat: move community supabase request to api layer

* chore: pr review checkpoint

* chore: PR fixes

* feat: implement role-based authorization middleware and align the community features with the developer guide.

- Added role-based authorization middleware for organization and course membership checks, including `orgMemberMiddleware`, `orgTeamMemberMiddleware`, and `courseMemberMiddleware`.
- Introduced route-specific middleware for comment and question authorization, ensuring only authors or team members can modify content.
- Updated community routes to utilize the new middleware for enhanced security and access control.
- Refactored community services to streamline upvote functionality and improve error handling.
- Enhanced UI components for community interactions, including new comment and question pages, and improved state management for user actions.
- Updated documentation to reflect new middleware usage and best practices for authorization in the API layer.

* feat: enhance course and organization routes with middleware for improved access control

- Added `courseMemberMiddleware` to course download and clone routes to enforce membership checks.
- Updated lesson route to include `courseMemberMiddleware` for PDF downloads.
- Integrated `orgMemberMiddleware` into organization routes for team and audience access, ensuring only authorized members can retrieve data.
- Refactored lesson and organization routes for consistency and clarity in middleware usage.

* chore: streamline dashboard and API development scripts in package.json

- Consolidated dashboard and API development commands to include all relevant filters in a single line for improved clarity and ease of use.
- Removed redundant full command variants to simplify the script section.

---------

Co-authored-by: Rotimi Best <best@classroomio.com>
This commit is contained in:
Adeyemi Adetunji
2025-12-22 19:57:27 +01:00
committed by GitHub
co-authored by Rotimi Best
parent 9fb6c7cbaa
commit bc9e3b41ae
41 changed files with 2699 additions and 1598 deletions
+175
View File
@@ -0,0 +1,175 @@
import {
ZCommunityComment,
ZCommunityCommentDelete,
ZCommunityQuestion,
ZCommunityQuestionUpdate,
ZCommunityQuestions,
ZGetCommunity,
ZGetCommunityCommentQuestionId,
ZNewCommunityQuestion,
ZUpvotePost,
ZUpvotePostParam
} from '@cio/utils/validation/community';
import {
createComment,
createQuestion,
deleteComment,
deleteCommentsByQuestionId,
deleteQuestion,
editQuestion,
fetchCommunityQuestion,
fetchCommunityQuestions,
upvote
} from '@api/services/community';
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { commentAuthorOrTeamMiddleware } from './middlewares/comment-author-or-team';
import { handleError } from '@api/utils/errors';
import { orgMemberMiddleware } from '@api/middlewares/org-member';
import { orgTeamMemberMiddleware } from '@api/middlewares/org-team-member';
import { questionAuthorOrTeamMiddleware } from './middlewares/question-author-or-team';
import { zValidator } from '@hono/zod-validator';
export const communityRouter = new Hono()
.get('/', authMiddleware, orgMemberMiddleware, zValidator('query', ZCommunityQuestions), async (c) => {
try {
const { orgId } = c.req.valid('query');
const result = await fetchCommunityQuestions(orgId);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to load community posts');
}
})
.get('/:slug', authMiddleware, orgMemberMiddleware, zValidator('param', ZCommunityQuestion), async (c) => {
try {
const { slug } = c.req.valid('param');
const result = await fetchCommunityQuestion({ slug });
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to load community post');
}
})
.post('/', authMiddleware, orgMemberMiddleware, zValidator('json', ZNewCommunityQuestion), async (c) => {
try {
const { title, body, courseId, organizationId, authorProfileId, votes, slug } = c.req.valid('json');
const result = await createQuestion({
title,
body,
courseId,
organizationId,
authorProfileId,
votes,
slug
});
return c.json({ success: true, data: result }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create community post');
}
})
.put(
'/:id',
authMiddleware,
questionAuthorOrTeamMiddleware,
zValidator('param', ZGetCommunity),
zValidator('json', ZCommunityQuestionUpdate),
async (c) => {
try {
const { id } = c.req.valid('param');
const { title, body, courseId } = c.req.valid('json');
const result = await editQuestion({ id, title, body, courseId });
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to update community post');
}
}
)
.delete('/:id', authMiddleware, questionAuthorOrTeamMiddleware, zValidator('param', ZGetCommunity), async (c) => {
try {
const { id } = c.req.valid('param');
const result = await deleteQuestion({ id });
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to delete community post');
}
})
.post(
'/:id/comment',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZGetCommunity),
zValidator('json', ZCommunityComment),
async (c) => {
try {
const { id } = c.req.valid('param');
console.log('question id', id);
const { body, authorProfileId, votes } = c.req.valid('json');
const result = await createComment({
body,
questionId: id,
authorProfileId,
votes
});
return c.json({ success: true, data: result }, 201);
} catch (error) {
return handleError(c, error, 'Failed to submit comment');
}
}
)
.post(
'/:id/upvote',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZUpvotePostParam),
zValidator('json', ZUpvotePost),
async (c) => {
try {
const { id } = c.req.valid('param');
const { isQuestion } = c.req.valid('json');
const result = await upvote({
id,
isQuestion
});
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to upvote post');
}
}
)
.delete(
'/:id/comment',
authMiddleware,
commentAuthorOrTeamMiddleware,
zValidator('param', ZCommunityCommentDelete),
async (c) => {
const { id } = c.req.valid('param');
const result = await deleteComment({ id });
return c.json({ success: true, data: result }, 200);
}
)
.delete(
'/:questionId/comments',
authMiddleware,
orgTeamMemberMiddleware,
zValidator('param', ZGetCommunityCommentQuestionId),
async (c) => {
const { questionId } = c.req.valid('param');
const result = await deleteCommentsByQuestionId({ questionId });
return c.json({ success: true, data: result }, 200);
}
);
+1
View File
@@ -0,0 +1 @@
export * from './community';
@@ -0,0 +1,81 @@
import { Context, Next } from 'hono';
import { ErrorCodes } from '@api/utils/errors';
import { getCommentAuthorAndCourse } from '@cio/db/queries/community';
import { isUserCourseTeamMember } from '@cio/db/queries/group';
/**
* Middleware to check if user is comment author OR tutor/admin in course
* Requires authMiddleware to be applied first
* Expects comment ID in route params as 'id'
*/
export const commentAuthorOrTeamMiddleware = 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('id');
if (!commentId) {
return c.json(
{
success: false,
error: 'Comment ID is required',
code: 'COMMENT_ID_REQUIRED'
},
400
);
}
const comment = await getCommentAuthorAndCourse(commentId);
if (!comment) {
return c.json(
{
success: false,
error: 'Comment not found',
code: ErrorCodes.COMMUNITY_QUESTION_NOT_FOUND
},
404
);
}
const isAuthor = comment.authorId === user.id;
if (isAuthor) {
await next();
return;
}
const isTeamMember = await isUserCourseTeamMember(comment.courseId, user.id);
if (!isTeamMember) {
return c.json(
{
success: false,
error: 'Only the author or course team members can perform this action',
code: ErrorCodes.ORG_TEAM_NOT_AUTHORIZED
},
403
);
}
await next();
} catch (error) {
console.error('Error in commentAuthorOrTeamMiddleware:', error);
return c.json(
{
success: false,
error: 'Failed to verify comment authorization',
code: 'COMMENT_AUTH_CHECK_FAILED'
},
500
);
}
};
@@ -0,0 +1,76 @@
import { Context, Next } from 'hono';
import { ErrorCodes } from '@api/utils/errors';
import { getQuestionAuthorAndCourse } from '@cio/db/queries/community';
import { isUserCourseTeamMember } from '@cio/db/queries/group';
/**
* Middleware to check if user is question author OR tutor/admin in course
* Requires authMiddleware to be applied first
* Expects question ID in route params as 'id'
*/
export const questionAuthorOrTeamMiddleware = async (c: Context, next: Next) => {
try {
const user = c.get('user');
if (!user) {
return c.json(
{
success: false,
error: 'Unauthorized',
code: 'UNAUTHORIZED'
},
401
);
}
const questionId = c.req.param('id');
if (!questionId) {
return c.json(
{
success: false,
error: 'Question ID is required',
code: 'QUESTION_ID_REQUIRED'
},
400
);
}
const question = await getQuestionAuthorAndCourse(questionId);
if (!question) {
return c.json(
{
success: false,
error: 'Question not found',
code: ErrorCodes.COMMUNITY_QUESTION_NOT_FOUND
},
404
);
}
const isAuthor = question.authorId === user.id;
const isTeamMember = await isUserCourseTeamMember(question.courseId, user.id);
if (!isAuthor && !isTeamMember) {
return c.json(
{
success: false,
error: 'Only the author or course team members can perform this action',
code: ErrorCodes.ORG_TEAM_NOT_AUTHORIZED
},
403
);
}
await next();
} catch (error) {
console.error('Error in questionAuthorOrTeamMiddleware:', error);
return c.json(
{
success: false,
error: 'Failed to verify question authorization',
code: 'QUESTION_AUTH_CHECK_FAILED'
},
500
);
}
};
+8 -1
View File
@@ -9,17 +9,21 @@ import {
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { cloneCourse } from '@api/services/course/clone';
import { courseMemberMiddleware } from '@api/middlewares/course-member';
import { generateCertificate } from '@api/utils/certificate';
import { generateCoursePdf } from '@api/utils/course';
import { handleError } from '@api/utils/errors';
import { katexRouter } from '@api/routes/course/katex';
import { lessonRouter } from '@api/routes/course/lesson';
import { orgMemberMiddleware } from '@api/middlewares/org-member';
import { presignRouter } from '@api/routes/course/presign';
import { zValidator } from '@hono/zod-validator';
export const courseRouter = new Hono()
.post(
'/:courseId/download/certificate',
authMiddleware,
courseMemberMiddleware,
zValidator('param', ZCourseDownloadParam),
zValidator('json', ZCertificateDownload),
async (c) => {
@@ -42,6 +46,8 @@ export const courseRouter = new Hono()
)
.post(
'/:courseId/download/content',
authMiddleware,
courseMemberMiddleware,
zValidator('param', ZCourseDownloadParam),
zValidator('json', ZCourseDownloadContent),
async (c) => {
@@ -64,6 +70,7 @@ export const courseRouter = new Hono()
.post(
'/:courseId/clone',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZCourseCloneParam),
zValidator('json', ZCourseClone),
async (c) => {
@@ -90,5 +97,5 @@ export const courseRouter = new Hono()
}
)
.route('/katex', katexRouter)
.route('/lesson', lessonRouter)
.route('/:courseId/lesson', lessonRouter)
.route('/presign', presignRouter);
+19 -13
View File
@@ -1,20 +1,26 @@
import { Hono } from '@api/utils/hono';
import { ZLessonDownloadContent } from '@cio/utils/validation/course';
import { courseMemberMiddleware } from '@api/middlewares/course-member';
import { generateLessonPdf } from '@api/utils/lesson';
import { zValidator } from '@hono/zod-validator';
export const lessonRouter = new Hono().post('/download/pdf', zValidator('json', ZLessonDownloadContent), async (c) => {
const validatedData = c.req.valid('json');
export const lessonRouter = new Hono().post(
'/download/pdf',
courseMemberMiddleware,
zValidator('json', ZLessonDownloadContent),
async (c) => {
const validatedData = c.req.valid('json');
const pdfBuffer = await generateLessonPdf(validatedData);
const pdfBuffer = await generateLessonPdf(validatedData);
c.header('Content-Type', 'application/pdf');
return c.body(
new ReadableStream({
start(controller) {
controller.enqueue(pdfBuffer);
controller.close();
}
})
);
});
c.header('Content-Type', 'application/pdf');
return c.body(
new ReadableStream({
start(controller) {
controller.enqueue(pdfBuffer);
controller.close();
}
})
);
}
);
+2
View File
@@ -3,11 +3,13 @@ import { ZDashStats } from '@cio/utils/validation/dash';
import { authMiddleware } from '@api/middlewares/auth';
import { getOrganisationAnalytics } from '@api/services/dash';
import { handleError } from '@api/utils/errors';
import { orgMemberMiddleware } from '@api/middlewares/org-member';
import { zValidator } from '@hono/zod-validator';
export const dashAnalyticsRouter = new Hono().get(
'/stats',
authMiddleware,
orgMemberMiddleware,
zValidator('query', ZDashStats),
async (c) => {
try {
+2 -1
View File
@@ -1,10 +1,11 @@
import { Hono } from '@api/utils/hono';
import { ZDeliverEmail } from '@cio/utils/validation/mail';
import { apiKeyMiddleware } from '@api/middlewares/api-key';
import { deliverEmail } from '@cio/email';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
export const mailRouter = new Hono().post('/', zValidator('json', ZDeliverEmail), async (c) => {
export const mailRouter = new Hono().post('/', apiKeyMiddleware, zValidator('json', ZDeliverEmail), async (c) => {
const validatedData = c.req.valid('json');
try {
@@ -34,6 +34,7 @@ import { authMiddleware } from '@api/middlewares/auth';
import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key';
import { handleError } from '@api/utils/errors';
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { orgMemberMiddleware } from '@api/middlewares/org-member';
import { zValidator } from '@hono/zod-validator';
export const organizationRouter = new Hono()
@@ -66,7 +67,7 @@ export const organizationRouter = new Hono()
* Gets organization team members (non-students)
* Requires authentication
*/
.get('/:orgId/team', authMiddleware, zValidator('param', ZGetOrganizationTeam), async (c) => {
.get('/:orgId/team', authMiddleware, orgMemberMiddleware, zValidator('param', ZGetOrganizationTeam), async (c) => {
try {
const { orgId } = c.req.valid('param');
const team = await getOrgTeam(orgId);
@@ -144,22 +145,28 @@ export const organizationRouter = new Hono()
* Gets organization audience (students)
* Requires authentication
*/
.get('/:orgId/audience', authMiddleware, zValidator('param', ZGetOrganizationAudience), async (c) => {
try {
const { orgId } = c.req.valid('param');
const audience = await getOrgAudience(orgId);
.get(
'/:orgId/audience',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZGetOrganizationAudience),
async (c) => {
try {
const { orgId } = c.req.valid('param');
const audience = await getOrgAudience(orgId);
return c.json(
{
success: true,
data: audience
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to fetch organization audience');
return c.json(
{
success: true,
data: audience
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to fetch organization audience');
}
}
})
)
/**
* GET /organization/courses
* Gets courses by organization siteName
@@ -305,6 +312,7 @@ export const organizationRouter = new Hono()
.put(
'/:orgId',
authMiddleware,
orgAdminMiddleware,
zValidator('param', ZUpdateOrganizationParam),
zValidator('json', ZUpdateOrganization),
async (c) => {