feat: certificates, landing pages, ai tutor, website update, compliance
This commit is contained in:
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user