feat: certificates, landing pages, ai tutor, website update, compliance

This commit is contained in:
Rotimi Best
2026-05-18 03:47:22 +01:00
parent c31ad63955
commit ac3a2c14a7
644 changed files with 56050 additions and 5439 deletions
+39
View File
@@ -0,0 +1,39 @@
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { courseTeamMemberMiddleware } from '@api/middlewares/course-team-member';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
import { ZCourseAiTutorOverride } from '@cio/utils/validation/agent';
import { getCourseAiTutorSettingsView, updateCourseAiTutorOverrideService } from '@api/services/agent/tutor-config';
/**
* Per-course AI tutor override.
*
* GET /course/:courseId/ai-tutor → { org, override, effective }
* PUT /course/:courseId/ai-tutor → updates the per-course override slot.
* Mounted at /course/:courseId/ai-tutor.
*/
export const courseAiTutorRouter = new Hono()
.get('/', authMiddleware, courseTeamMemberMiddleware, async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const courseId = c.req.param('courseId') as string;
const view = await getCourseAiTutorSettingsView(orgId, courseId);
return c.json({ success: true as const, data: view });
} catch (error) {
return handleError(c, error, 'Failed to fetch course AI tutor settings');
}
})
.put('/', authMiddleware, courseTeamMemberMiddleware, zValidator('json', ZCourseAiTutorOverride), async (c) => {
try {
const courseId = c.req.param('courseId') as string;
const patch = c.req.valid('json');
const updated = await updateCourseAiTutorOverrideService(courseId, patch);
return c.json({ success: true as const, data: updated });
} catch (error) {
return handleError(c, error, 'Failed to update course AI tutor override');
}
});
+93 -19
View File
@@ -1,5 +1,5 @@
import {
ZCertificateDownload,
ZCertificateDownloadRequest,
ZCourseClone,
ZCourseCloneParam,
ZCourseCreate,
@@ -31,6 +31,7 @@ import { getCourseTags, replaceCourseTags } from '@api/services/tag';
import { Hono } from '@api/utils/hono';
import { attendanceRouter } from '@api/routes/course/attendance';
import { courseAiTutorRouter } from '@api/routes/course/ai-tutor';
import { authMiddleware } from '@api/middlewares/auth';
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
import { cloneCourse } from '@api/services/course/clone';
@@ -43,9 +44,11 @@ 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 { generateCertificatePdf, generateCertificatePng } from '@api/utils/certificate';
import { assembleCertificateRender, assembleOwnerPreviewRender } from '@api/services/course/certificate';
import { isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group';
import { generateCoursePdf } from '@api/utils/course';
import { handleError } from '@api/utils/errors';
import { AppError, ErrorCodes, 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';
@@ -62,6 +65,36 @@ import { submissionRouter } from '@api/routes/course/submission';
import { updateCourseLandingPageService } from '@api/services/course/landing-page';
import { zValidator } from '@hono/zod-validator';
function slugifyForFilename(value: string): string {
return (
value
.normalize('NFKD')
.replace(/[^a-zA-Z0-9 ]/g, '')
.trim()
.replace(/\s+/g, '-')
.slice(0, 60) || 'certificate'
);
}
async function loadCertificateInput(
courseId: string,
userId: string,
body: import('@cio/utils/validation/course').TCertificateDownloadRequest
) {
if (body.previewMode) {
const isTeam = await isCourseTeamMemberOrOrgAdmin(courseId, userId);
if (!isTeam) {
throw new AppError('Only course team members can preview certificate designs', ErrorCodes.UNAUTHORIZED, 403);
}
return assembleOwnerPreviewRender(courseId, userId, body);
}
await assertCertificateDownloadAllowed(courseId, userId);
return assembleCertificateRender(courseId, body);
}
const enrollRateLimit = createRateLimiter({
windowMs: 60 * 60 * 1000,
maxRequests: 20,
@@ -454,27 +487,67 @@ export const courseRouter = new Hono()
authMiddleware,
courseMemberMiddleware,
zValidator('param', ZCourseDownloadParam),
zValidator('json', ZCertificateDownload),
zValidator('json', ZCertificateDownloadRequest),
async (c) => {
const { courseId } = c.req.valid('param');
const user = c.get('user')!;
await assertCertificateDownloadAllowed(courseId, user.id);
try {
const { courseId } = c.req.valid('param');
const user = c.get('user')!;
const body = c.req.valid('json');
const validatedData = c.req.valid('json');
const input = await loadCertificateInput(courseId, user.id, body);
const buffer = await generateCertificatePdf(input);
const result = await generateCertificate(validatedData);
c.header('Content-Type', 'application/pdf');
c.header(
'Content-Disposition',
`attachment; filename="certificate-${slugifyForFilename(input.data.courseName)}.pdf"`
);
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(buffer);
controller.close();
}
})
);
} catch (error) {
return handleError(c, error, 'Failed to download certificate');
}
}
)
.post(
'/:courseId/download/certificate/png',
authMiddleware,
courseMemberMiddleware,
zValidator('param', ZCourseDownloadParam),
zValidator('json', ZCertificateDownloadRequest),
async (c) => {
try {
const { courseId } = c.req.valid('param');
const user = c.get('user')!;
const body = c.req.valid('json');
return c.body(
new ReadableStream({
start(controller) {
controller.enqueue(result);
controller.close();
}
})
);
const input = await loadCertificateInput(courseId, user.id, body);
const buffer = await generateCertificatePng(input);
c.header('Content-Type', 'image/png');
c.header(
'Content-Disposition',
`attachment; filename="certificate-${slugifyForFilename(input.data.courseName)}.png"`
);
return c.body(
new ReadableStream({
start(controller) {
controller.enqueue(buffer);
controller.close();
}
})
);
} catch (error) {
return handleError(c, error, 'Failed to download certificate image');
}
}
)
.post(
@@ -542,4 +615,5 @@ export const courseRouter = new Hono()
.route('/:courseId/newsfeed', newsfeedRouter)
.route('/:courseId/members', membersRouter)
.route('/:courseId/invites', invitesRouter)
.route('/:courseId/ai-tutor', courseAiTutorRouter)
.route('/presign', presignRouter);