import { ZCertificateDownload, ZCourseClone, ZCourseCloneParam, ZCourseCreate, ZCourseDeleteParam, ZCourseDownloadContent, ZCourseDownloadParam, ZCourseEnrollBody, ZCourseEnrollParam, ZCourseGetBySlugParam, ZCourseGetParam, ZCourseGetQuery, ZCourseLandingPageUpdate, ZCourseProgressParam, ZCourseProgressQuery, ZCourseUpdate, ZCourseUpdateParam } from '@cio/utils/validation/course'; import { ZCourseTagAssignment, ZCourseTagParam } from '@cio/utils/validation/tag'; import { createCourse, deleteCourse, getCourse, getCourseAnalytics, getCourseProgress, updateCourse } from '@api/services/course/course'; import { assertCertificateDownloadAllowed, evaluateCourseCertification } from '@api/services/course/completion'; import { getCourseTags, replaceCourseTags } from '@api/services/tag'; import { Hono } from '@api/utils/hono'; import { attendanceRouter } from '@api/routes/course/attendance'; import { authMiddleware } from '@api/middlewares/auth'; import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key'; import { cloneCourse } from '@api/services/course/clone'; import { complianceRouter } from '@api/routes/course/compliance'; import { contentRouter } from '@api/routes/course/content'; import { courseMemberMiddleware } from '@api/middlewares/course-member'; import { courseTeamMemberOrAutomationKeyMiddleware } from '@api/middlewares/course-team-member-or-automation-key'; import { courseTeamMemberMiddleware } from '@api/middlewares/course-team-member'; import { createRateLimiter } from '@api/middlewares/rate-limiter'; import { enrollInCourse } from '@api/services/course/invite'; import { exerciseRouter } from '@api/routes/course/exercise'; import { extractClientIp } from '@api/utils/redis/key-generators'; import { generateCertificate } from '@api/utils/certificate'; import { generateCoursePdf } from '@api/utils/course'; import { handleError } from '@api/utils/errors'; import { invitesRouter } from '@api/routes/course/invite'; import { katexRouter } from '@api/routes/course/katex'; import { lessonRouter } from '@api/routes/course/lesson'; import { markRouter } from '@api/routes/course/mark'; import { membersRouter } from '@api/routes/course/people'; import { newsfeedRouter } from '@api/routes/course/newsfeed'; import { orgAdminMiddleware } from '@api/middlewares/org-admin'; import { orgMemberMiddleware } from '@api/middlewares/org-member'; import { paymentRequestRouter } from '@api/routes/course/payment-request'; import { presignRouter } from '@api/routes/course/presign'; import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage'; import { sectionRouter } from '@api/routes/course/section'; import { submissionRouter } from '@api/routes/course/submission'; import { updateCourseLandingPageService } from '@api/services/course/landing-page'; import { zValidator } from '@hono/zod-validator'; const enrollRateLimit = createRateLimiter({ windowMs: 60 * 60 * 1000, maxRequests: 20, message: 'Too many enrollment attempts. Please try again later.', keyGenerator: (c) => { const user = c.get('user'); const actor = user?.id ? `user:${user.id}` : `ip:${extractClientIp(c)}`; return `enroll:${actor}:${c.req.param('courseId')}`; } }); export const courseRouter = new Hono() /** * GET /course/slug/:slug * Gets a course by slug (public route, no authentication required) * Used for public course landing pages */ .get('/slug/:slug', zValidator('param', ZCourseGetBySlugParam), async (c) => { try { const { slug } = c.req.valid('param'); const course = await getCourse(undefined, slug); return c.json( { success: true, data: course }, 200 ); } catch (error) { return handleError(c, error, 'Failed to fetch course by slug'); } }) /** * POST /course * Creates a new course with group, group member, and default newsfeed * Requires authentication and organization membership */ .post('/', authMiddleware, orgAdminMiddleware, zValidator('json', ZCourseCreate), async (c) => { try { const user = c.get('user')!; const validatedData = c.req.valid('json'); const result = await createCourse(user.id, validatedData); return c.json( { success: true, data: result }, 201 ); } catch (error) { return handleError(c, error, 'Failed to create course'); } }) /** * POST /course/:courseId/enroll * Unified enrollment: free courses enroll directly; paid/invited require inviteToken. */ .post( '/:courseId/enroll', authMiddleware, enrollRateLimit, zValidator('param', ZCourseEnrollParam), zValidator('json', ZCourseEnrollBody), async (c) => { try { const { courseId } = c.req.valid('param'); const body = c.req.valid('json'); const user = c.get('user')!; const result = await enrollInCourse( courseId, { id: user.id, email: user.email, emailVerified: user.emailVerified }, body, { ipAddress: extractClientIp(c), userAgent: c.req.header('user-agent') || null } ); return c.json( { success: true, data: result }, 200 ); } catch (error) { return handleError(c, error, 'Failed to enroll'); } } ) /** * GET /course/:courseId/tags * Gets tags assigned to a course (organization admin only) */ .get('/:courseId/tags', authMiddleware, orgMemberMiddleware, zValidator('param', ZCourseTagParam), async (c) => { try { const orgId = c.req.header('cio-org-id')!; const { courseId } = c.req.valid('param'); const tags = await getCourseTags(orgId, courseId); return c.json( { success: true, data: tags }, 200 ); } catch (error) { return handleError(c, error, 'Failed to fetch course tags'); } }) /** * PUT /course/:courseId/tags * Replaces all tags assigned to a course (organization admin only) */ .put( '/:courseId/tags', authMiddleware, orgAdminMiddleware, zValidator('param', ZCourseTagParam), zValidator('json', ZCourseTagAssignment), async (c) => { try { const orgId = c.req.header('cio-org-id')!; const { courseId } = c.req.valid('param'); const data = c.req.valid('json'); const tags = await replaceCourseTags(orgId, courseId, data); return c.json( { success: true, data: tags }, 200 ); } catch (error) { return handleError(c, error, 'Failed to assign course tags'); } } ) /** * GET /course/:courseId * Gets a course by ID or slug with all related data (group, members, lessons, sections, attendance) * Query param: slug (optional) - if provided, courseId is ignored and course is fetched by slug * Requires authentication and course membership */ .get( '/:courseId', courseMemberMiddleware, zValidator('param', ZCourseGetParam), zValidator('query', ZCourseGetQuery), async (c) => { try { const { courseId } = c.req.valid('param'); const { slug } = c.req.valid('query'); const user = c.get('user')!; const course = await getCourse(slug ? undefined : courseId, slug, user.id); return c.json( { success: true, data: course }, 200 ); } catch (error) { return handleError(c, error, 'Failed to fetch course'); } } ) /** * PUT /course/:courseId/landing-page * Updates landing-page-facing course fields such as copy, media, pricing, and reviews. * Requires authentication and course team membership, or an automation key with course write scope. */ .put( '/:courseId/landing-page', authOrAutomationKeyMiddleware, courseTeamMemberOrAutomationKeyMiddleware(['course:write']), zValidator('param', ZCourseUpdateParam), zValidator('json', ZCourseLandingPageUpdate), async (c) => { try { const { courseId } = c.req.valid('param'); const payload = c.req.valid('json'); const automationKey = c.get('automationKey'); if (automationKey?.type === 'mcp') { await assertMcpAutomationUsageAllowed(automationKey, 'update_course_landing_page'); } const result = await updateCourseLandingPageService(courseId, payload); if (automationKey?.type === 'mcp') { await recordMcpAutomationUsage(automationKey, 'update_course_landing_page', { courseId }); } return c.json( { success: true, data: result }, 200 ); } catch (error) { return handleError(c, error, 'Failed to update course landing page'); } } ) /** * PUT /course/:courseId * Updates a course * Requires authentication and course membership (admin/tutor role) */ .put( '/:courseId', authMiddleware, courseTeamMemberMiddleware, zValidator('param', ZCourseUpdateParam), zValidator('json', ZCourseUpdate), async (c) => { try { const { courseId } = c.req.valid('param'); const validatedData = c.req.valid('json'); const { tagIds, ...courseData } = validatedData; const result = await updateCourse(courseId, courseData); if (tagIds !== undefined) { const orgId = c.req.header('cio-org-id'); if (!orgId) { return c.json( { success: false, error: 'Organization context not available', code: 'ORG_CONTEXT_MISSING' }, 500 ); } const user = c.get('user')!; const tags = await replaceCourseTags(orgId, courseId, { tagIds }, { updatedByUserId: user.id }); return c.json( { success: true, data: { ...result, tags } }, 200 ); } return c.json( { success: true, data: result }, 200 ); } catch (error) { return handleError(c, error, 'Failed to update course'); } } ) /** * DELETE /course/:courseId * Soft deletes a course by setting status to 'DELETED' * Requires authentication and course membership (admin/tutor role) */ .delete( '/:courseId', authMiddleware, courseTeamMemberMiddleware, zValidator('param', ZCourseDeleteParam), async (c) => { try { const { courseId } = c.req.valid('param'); const result = await deleteCourse(courseId); return c.json( { success: true, data: result }, 200 ); } catch (error) { return handleError(c, error, 'Failed to delete course'); } } ) /** * GET /course/:courseId/progress * Gets course progress for a profile * Query param: profileId (required) * Requires authentication and course membership */ .get( '/:courseId/progress', authMiddleware, courseMemberMiddleware, zValidator('param', ZCourseProgressParam), zValidator('query', ZCourseProgressQuery), async (c) => { try { const { courseId } = c.req.valid('param'); const { profileId } = c.req.valid('query'); const progress = await getCourseProgress(courseId, profileId); return c.json( { success: true, data: progress }, 200 ); } catch (error) { return handleError(c, error, 'Failed to fetch course progress'); } } ) /** * GET /course/:courseId/certification-evaluation * Full certification eligibility for the current user (blockers for UI). */ .get( '/:courseId/certification-evaluation', authMiddleware, courseMemberMiddleware, zValidator('param', ZCourseProgressParam), async (c) => { try { const { courseId } = c.req.valid('param'); const user = c.get('user')!; const data = await evaluateCourseCertification(courseId, user.id); return c.json( { success: true, data }, 200 ); } catch (error) { return handleError(c, error, 'Failed to evaluate certification'); } } ) /** * GET /course/:courseId/analytics * Gets course analytics including student progress, completion rates, and grades * Requires authentication and course membership (admin/tutor role) */ .get( '/:courseId/analytics', authMiddleware, courseTeamMemberMiddleware, zValidator('param', ZCourseProgressParam), async (c) => { try { const { courseId } = c.req.valid('param'); const analytics = await getCourseAnalytics(courseId); return c.json( { success: true, data: analytics }, 200 ); } catch (error) { return handleError(c, error, 'Failed to fetch course analytics'); } } ) .post( '/:courseId/download/certificate', authMiddleware, courseMemberMiddleware, zValidator('param', ZCourseDownloadParam), zValidator('json', ZCertificateDownload), async (c) => { const { courseId } = c.req.valid('param'); const user = c.get('user')!; await assertCertificateDownloadAllowed(courseId, user.id); const validatedData = c.req.valid('json'); const result = await generateCertificate(validatedData); c.header('Content-Type', 'application/pdf'); c.header('Content-Disposition', `attachment; filename="certificate-${validatedData.courseName}.pdf"`); return c.body( new ReadableStream({ start(controller) { controller.enqueue(result); controller.close(); } }) ); } ) .post( '/:courseId/download/content', authMiddleware, courseMemberMiddleware, zValidator('param', ZCourseDownloadParam), zValidator('json', ZCourseDownloadContent), async (c) => { const validatedData = c.req.valid('json'); const pdfBuffer = await generateCoursePdf(validatedData); c.header('Content-Type', 'application/pdf'); return c.body( new ReadableStream({ start(controller) { controller.enqueue(pdfBuffer); controller.close(); } }) ); } ) .post( '/:courseId/clone', authMiddleware, orgMemberMiddleware, zValidator('param', ZCourseCloneParam), zValidator('json', ZCourseClone), async (c) => { try { const { courseId } = c.req.valid('param'); const validatedData = c.req.valid('json'); const { title, description, slug, organizationId } = validatedData; const user = c.get('user')!; // Clone the course const newCourse = await cloneCourse(courseId, title, user.id, description, slug, organizationId); return c.json( { success: true, course: newCourse }, 200 ); } catch (error) { return handleError(c, error, 'Failed to clone course'); } } ) .route('/katex', katexRouter) .route('/:courseId/payment-request', paymentRequestRouter) .route('/:courseId/content', contentRouter) .route('/:courseId/compliance', complianceRouter) .route('/:courseId/section', sectionRouter) .route('/:courseId/lesson', lessonRouter) .route('/:courseId/exercise', exerciseRouter) .route('/:courseId/submission', submissionRouter) .route('/:courseId/attendance', attendanceRouter) .route('/:courseId/mark', markRouter) .route('/:courseId/newsfeed', newsfeedRouter) .route('/:courseId/members', membersRouter) .route('/:courseId/invites', invitesRouter) .route('/presign', presignRouter);