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 <[email protected]>
This commit is contained in:
co-authored by
Rotimi Best
parent
9fb6c7cbaa
commit
bc9e3b41ae
@@ -17,6 +17,7 @@ import { dashAnalyticsRouter } from '@api/routes/dash';
|
||||
import { mediaRouter } from '@api/routes/media';
|
||||
import { onboardingRouter } from '@api/routes/onboarding';
|
||||
import { organizationRouter } from '@api/routes/organization';
|
||||
import { communityRouter } from '@api/routes/community';
|
||||
|
||||
// Create Hono app with chaining for RPC support
|
||||
export const app = new Hono()
|
||||
@@ -77,6 +78,7 @@ export const app = new Hono()
|
||||
.route('/media', mediaRouter)
|
||||
.route('/organization', organizationRouter)
|
||||
.route('/dash', dashAnalyticsRouter)
|
||||
.route('/community', communityRouter)
|
||||
|
||||
// Error handling
|
||||
.onError((err, c) => {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Context, Next } from 'hono';
|
||||
|
||||
import { ErrorCodes } from '@api/utils/errors';
|
||||
import { isUserCourseMember } from '@cio/db/queries/group';
|
||||
|
||||
/**
|
||||
* Middleware to check if the authenticated user is a member of a course's group
|
||||
* Requires authMiddleware to be applied first
|
||||
* Extracts courseId from request params, query, or body
|
||||
*/
|
||||
export const courseMemberMiddleware = async (c: Context, next: Next) => {
|
||||
try {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
code: 'UNAUTHORIZED'
|
||||
},
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
// Try to get courseId from params or query
|
||||
// Note: Body parsing should happen after validation middleware
|
||||
const courseId = c.req.param('courseId') || c.req.query('courseId');
|
||||
|
||||
if (!courseId) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Course ID is required',
|
||||
code: 'COURSE_ID_REQUIRED'
|
||||
},
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const isMember = await isUserCourseMember(courseId, user.id);
|
||||
if (!isMember) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'You must be a member of this course to perform this action',
|
||||
code: ErrorCodes.ORG_TEAM_NOT_AUTHORIZED
|
||||
},
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
await next();
|
||||
} catch (error) {
|
||||
console.error('Error in courseMemberMiddleware:', error);
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to verify course membership',
|
||||
code: 'COURSE_MEMBER_CHECK_FAILED'
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Context, Next } from 'hono';
|
||||
|
||||
import { ErrorCodes } from '@api/utils/errors';
|
||||
import { isUserOrgMember } from '@cio/db/queries/organization';
|
||||
|
||||
/**
|
||||
* Middleware to check if the authenticated user is a member of the organization
|
||||
* Requires authMiddleware to be applied first
|
||||
* Expects orgId in the 'cio-org-id' header
|
||||
*/
|
||||
export const orgMemberMiddleware = async (c: Context, next: Next) => {
|
||||
try {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
code: 'UNAUTHORIZED'
|
||||
},
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
const orgId = c.req.header('cio-org-id');
|
||||
|
||||
if (!orgId) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Organization ID is required',
|
||||
code: 'ORG_ID_REQUIRED'
|
||||
},
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const isMember = await isUserOrgMember(orgId, user.id);
|
||||
if (!isMember) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'UNAUTHORIZED',
|
||||
code: 'UNAUTHORIZED'
|
||||
},
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
await next();
|
||||
} catch (error) {
|
||||
console.error('Error in orgMemberMiddleware:', error);
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'INTERNAL_ERROR',
|
||||
code: 'INTERNAL_ERROR'
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Context, Next } from 'hono';
|
||||
|
||||
import { ErrorCodes } from '@api/utils/errors';
|
||||
import { isUserOrgTeamMember } from '@cio/db/queries/organization';
|
||||
|
||||
/**
|
||||
* Middleware to check if the authenticated user is a team member (ADMIN or TUTOR) of the organization
|
||||
* Requires authMiddleware to be applied first
|
||||
* Expects orgId in the 'cio-org-id' header
|
||||
*/
|
||||
export const orgTeamMemberMiddleware = async (c: Context, next: Next) => {
|
||||
try {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
code: 'UNAUTHORIZED'
|
||||
},
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
const orgId = c.req.header('cio-org-id');
|
||||
|
||||
if (!orgId) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Organization ID is required',
|
||||
code: 'ORG_ID_REQUIRED'
|
||||
},
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const isTeamMember = await isUserOrgTeamMember(orgId, user.id);
|
||||
if (!isTeamMember) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Only organization team members (admins or tutors) can perform this action',
|
||||
code: ErrorCodes.ORG_TEAM_NOT_AUTHORIZED
|
||||
},
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
await next();
|
||||
} catch (error) {
|
||||
console.error('Error in orgTeamMemberMiddleware:', error);
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to verify organization team member status',
|
||||
code: 'ORG_TEAM_MEMBER_CHECK_FAILED'
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import type { TCommunityAnswer, TCommunityQuestion, TCourse } from '@db/types';
|
||||
import {
|
||||
createCommunityQuestion,
|
||||
deleteCommentById,
|
||||
deleteCommentByQuestionId,
|
||||
deleteQuestionByQuestionId,
|
||||
editCommunityQuestion,
|
||||
getCommunityQuestion,
|
||||
getCommunityQuestions,
|
||||
submitComment,
|
||||
upvoteAnswer,
|
||||
upvoteQuestion
|
||||
} from '@cio/db/queries/community';
|
||||
|
||||
import { getCoursesByOrgId } from './organization';
|
||||
|
||||
export async function fetchCommunityQuestions(orgId: string): Promise<Partial<TCommunityQuestion>[]> {
|
||||
let discussions: Partial<TCommunityQuestion>[] = [];
|
||||
|
||||
let courses: TCourse[];
|
||||
|
||||
if (orgId) {
|
||||
courses = await getCoursesByOrgId(orgId);
|
||||
} else {
|
||||
throw new AppError(
|
||||
'Organization not found for the given site name',
|
||||
ErrorCodes.COMMUNITY_QUESTIONS_FETCH_FAILED,
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const courseIds = courses.map((course) => course.id);
|
||||
discussions = await getCommunityQuestions(courseIds);
|
||||
|
||||
return discussions;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch community questions:', error);
|
||||
throw new AppError('Failed to fetch community questions', ErrorCodes.COMMUNITY_QUESTIONS_FETCH_FAILED, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchCommunityQuestion({ slug }: Partial<TCommunityQuestion>) {
|
||||
try {
|
||||
const result = await getCommunityQuestion(slug);
|
||||
|
||||
if (!result) {
|
||||
throw new AppError('Community question not found', ErrorCodes.COMMUNITY_QUESTION_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return {
|
||||
id: result.id,
|
||||
title: result.title,
|
||||
body: result.body,
|
||||
votes: result.votes,
|
||||
createdAt: result.createdAt,
|
||||
courseId: result.courseId,
|
||||
courseTitle: result.course.title,
|
||||
slug: result.slug,
|
||||
authorId: result.author.id,
|
||||
authorFullname: result.author.fullname,
|
||||
authorAvatarUrl: result.author.avatarUrl,
|
||||
organizationId: result.organizationId,
|
||||
comments: result.comments
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch community question:', error);
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError('Failed to fetch community question', ErrorCodes.COMMUNITY_QUESTION_NOT_FOUND, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createQuestion({
|
||||
title,
|
||||
body,
|
||||
courseId,
|
||||
organizationId,
|
||||
authorProfileId,
|
||||
votes,
|
||||
slug
|
||||
}: Partial<TCommunityQuestion>) {
|
||||
try {
|
||||
const result = await createCommunityQuestion({
|
||||
title,
|
||||
body,
|
||||
courseId,
|
||||
organizationId,
|
||||
authorProfileId,
|
||||
votes,
|
||||
slug
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
throw new AppError('Failed to create community question', ErrorCodes.ADD_COMMUNITY_QUESTION_FAILED, 404);
|
||||
}
|
||||
|
||||
return {
|
||||
id: result.id,
|
||||
title: result.title,
|
||||
body: result.body,
|
||||
votes: result.votes,
|
||||
createdAt: result.createdAt,
|
||||
courseId: result.courseId,
|
||||
courseTitle: result.title,
|
||||
slug: result.slug,
|
||||
authorId: result.authorId
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to add community question:', error);
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError('Failed to add community question', ErrorCodes.ADD_COMMUNITY_QUESTION_FAILED, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function editQuestion({ id, title, body, courseId }: Partial<TCommunityQuestion>) {
|
||||
try {
|
||||
const result = await editCommunityQuestion({ id, title, body, courseId: courseId });
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to update community question:', error);
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError('Failed to update community question', ErrorCodes.UPDATE_COMMUNITY_QUESTION_FAILED, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createComment({ body, questionId, authorProfileId, votes }: Partial<TCommunityAnswer>) {
|
||||
try {
|
||||
const result = await submitComment({ body, questionId, authorProfileId, votes });
|
||||
|
||||
if (!result) {
|
||||
throw new AppError('Comment submission failed', ErrorCodes.COMMENT_SUBMISSION_FAILED, 404);
|
||||
}
|
||||
|
||||
return {
|
||||
id: result.id,
|
||||
body: result.body,
|
||||
votes: result.votes,
|
||||
createdAt: result.createdAt,
|
||||
authorId: result.authorProfileId
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to submit comment:', error);
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError('Failed to submit comment', ErrorCodes.COMMENT_SUBMISSION_FAILED, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function upvote({ id, isQuestion }: { id: string | number; isQuestion: boolean }) {
|
||||
try {
|
||||
let result;
|
||||
|
||||
if (isQuestion) {
|
||||
result = await upvoteQuestion({ id: Number(id) });
|
||||
} else {
|
||||
result = await upvoteAnswer({ id: String(id) });
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to upvote:', error);
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError('Failed to upvote', ErrorCodes.UPVOTE_FAILED, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteQuestion({ id }: Partial<TCommunityQuestion>) {
|
||||
try {
|
||||
// Delete all comments first, then delete the question
|
||||
await deleteCommentByQuestionId(id);
|
||||
const result = await deleteQuestionByQuestionId(id);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to delete question:', error);
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError('Failed to delete question', ErrorCodes.DELETE_QUESTION_FAILED, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteComment({ id }: Partial<TCommunityAnswer>) {
|
||||
try {
|
||||
const result = await deleteCommentById(id);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to delete comment:', error);
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError('Failed to delete comment', ErrorCodes.DELETE_COMMENT_FAILED, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCommentsByQuestionId({ questionId }: Partial<TCommunityAnswer>) {
|
||||
try {
|
||||
const result = await deleteCommentByQuestionId(questionId);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to delete comment:', error);
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError('Failed to delete comment', ErrorCodes.DELETE_COMMENT_FAILED, 500);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
updateOrganizationPlan
|
||||
} from '@cio/db/queries/organization';
|
||||
import {
|
||||
getCoursesById,
|
||||
getCoursesBySiteName,
|
||||
getCoursesBySiteNameForSetup,
|
||||
getExercisesBySiteName,
|
||||
@@ -128,6 +129,23 @@ export async function getCoursesByOrgSiteName(siteName: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets courses by organization orgId
|
||||
* @param orgId - The organization orgId
|
||||
* @returns Array of courses
|
||||
*/
|
||||
export async function getCoursesByOrgId(orgId: string) {
|
||||
try {
|
||||
return getCoursesById(orgId);
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to fetch courses',
|
||||
ErrorCodes.COURSES_FETCH_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets setup data for an organization
|
||||
* @param siteName - The organization site name
|
||||
|
||||
@@ -32,6 +32,16 @@ export const ErrorCodes = {
|
||||
ORG_ANALYTICS_FETCH_FAILED: 'ORG_ANALYTICS_FETCH_FAILED',
|
||||
NO_ORG_ID_PROVIDED: 'NO_ORG_ID_PROVIDED',
|
||||
|
||||
// Community errors
|
||||
UPDATE_COMMUNITY_QUESTION_FAILED: 'UPDATE_COMMUNITY_QUESTION_FAILED',
|
||||
ADD_COMMUNITY_QUESTION_FAILED: 'ADD_COMMUNITY_QUESTION_FAILED',
|
||||
COMMUNITY_QUESTIONS_FETCH_FAILED: 'COMMUNITY_QUESTIONS_FETCH_FAILED',
|
||||
COMMUNITY_QUESTION_NOT_FOUND: 'COMMUNITY_QUESTION_NOT_FOUND',
|
||||
COMMENT_SUBMISSION_FAILED: 'COMMENT_SUBMISSION_FAILED',
|
||||
UPVOTE_FAILED: 'UPVOTE_FAILED',
|
||||
DELETE_COMMENT_FAILED: 'DELETE_COMMENT_FAILED',
|
||||
DELETE_QUESTION_FAILED: 'DELETE_QUESTION_FAILED',
|
||||
|
||||
// Organization errors
|
||||
SITENAME_EXISTS: 'SITENAME_EXISTS',
|
||||
ORG_CREATE_FAILED: 'ORG_CREATE_FAILED',
|
||||
|
||||
Reference in New Issue
Block a user