feat: certification rules, content sanitization, handle draft course updates and reset content, better redis logging

This commit is contained in:
Rotimi Best
2026-03-30 08:24:55 +01:00
parent ff2ad59377
commit 5a8f50f67b
131 changed files with 5643 additions and 2134 deletions
+1 -2
View File
@@ -182,8 +182,7 @@ export async function cloneCourse(
currency: course.currency,
bannerImage: course.bannerImage,
isPublished: course.isPublished,
isCertificateDownloadable: course.isCertificateDownloadable,
certificateTheme: course.certificateTheme,
certificate: course.certificate,
status: course.status,
type: course.type
});
+275
View File
@@ -0,0 +1,275 @@
import * as schema from '@cio/db/schema';
import { AppError, ErrorCodes } from '@api/utils/errors';
import {
calcCourseProgressPercent,
isBeforeOrEqualDeadline,
scheduleCertificationCompletionWork
} from '@api/utils/course-completion';
import {
exerciseBelongsToCourse,
getExerciseTitleAndMaxPoints,
getStudentSubmissionsForExercise
} from '@cio/db/queries/course/certification-exercise';
import {
getCourseCertificationRow,
getCourseProgress as getCourseProgressQuery,
type TCourseCertificationRow
} from '@cio/db/queries/course/course';
import { ROLE } from '@cio/utils/constants';
import { db } from '@cio/db/drizzle';
import { eq } from 'drizzle-orm';
export type CertificationBlocker = {
code:
| 'CERT_PROGRESS'
| 'CERT_DEADLINE_PASSED'
| 'CERT_NO_CONTENT'
| 'CERT_FINAL_EXERCISE_NOT_SUBMITTED'
| 'CERT_FINAL_EXERCISE_PENDING_GRADE'
| 'CERT_FINAL_EXERCISE_SCORE'
| 'CERT_FINAL_EXERCISE_MISCONFIGURED';
params?: Record<string, string | number>;
};
export type CourseCompletionEvaluation = {
progressPercent: number;
certificationThreshold: number;
meetsThreshold: boolean;
eligibleForCertificate: boolean;
certificateEarnedAt: string | null;
blockers: CertificationBlocker[];
meetsFinalExerciseRule: boolean;
};
function percentFromTotal(total: number, maxPoints: number): number {
if (maxPoints <= 0) return 0;
return Math.round((total / maxPoints) * 100);
}
async function evaluateFinalExerciseRule(params: {
courseId: string;
requiredExerciseId: string | null | undefined;
minScorePercent: number | null | undefined;
groupMemberId: string | null;
}): Promise<{
meetsFinalExerciseRule: boolean;
blockers: CertificationBlocker[];
}> {
const { courseId, requiredExerciseId, minScorePercent, groupMemberId } = params;
if (!requiredExerciseId) {
return { meetsFinalExerciseRule: true, blockers: [] };
}
const belongs = await exerciseBelongsToCourse(requiredExerciseId, courseId);
if (!belongs) {
return { meetsFinalExerciseRule: true, blockers: [] };
}
const requiredPercent = minScorePercent ?? 100;
if (!groupMemberId) {
return {
meetsFinalExerciseRule: false,
blockers: [{ code: 'CERT_FINAL_EXERCISE_NOT_SUBMITTED' }]
};
}
const { title: exerciseTitle, maxPoints } = await getExerciseTitleAndMaxPoints(requiredExerciseId);
if (maxPoints <= 0) {
return {
meetsFinalExerciseRule: false,
blockers: [
{
code: 'CERT_FINAL_EXERCISE_MISCONFIGURED',
params: { exerciseTitle }
}
]
};
}
const submissions = await getStudentSubmissionsForExercise(groupMemberId, requiredExerciseId);
if (submissions.length === 0) {
return {
meetsFinalExerciseRule: false,
blockers: [
{
code: 'CERT_FINAL_EXERCISE_NOT_SUBMITTED',
params: { exerciseTitle }
}
]
};
}
const completed = submissions.filter((s) => s.gradingState === 'completed');
if (completed.length === 0) {
return {
meetsFinalExerciseRule: false,
blockers: [
{
code: 'CERT_FINAL_EXERCISE_PENDING_GRADE',
params: { exerciseTitle }
}
]
};
}
let bestPercent = 0;
for (const s of completed) {
const total = Number(s.total ?? 0);
const p = percentFromTotal(total, maxPoints);
if (p > bestPercent) bestPercent = p;
}
if (bestPercent < requiredPercent) {
return {
meetsFinalExerciseRule: false,
blockers: [
{
code: 'CERT_FINAL_EXERCISE_SCORE',
params: {
exerciseTitle,
bestPercent,
requiredPercent
}
}
]
};
}
return { meetsFinalExerciseRule: true, blockers: [] };
}
async function buildCertificationEvaluation(
courseId: string,
profileId: string,
courseRow: TCourseCertificationRow,
progressArg?: Awaited<ReturnType<typeof getCourseProgressQuery>>
): Promise<CourseCompletionEvaluation> {
const progress = progressArg ?? (await getCourseProgressQuery(courseId, profileId));
const certificate = courseRow.certificate ?? {};
const threshold = certificate.threshold ?? 100;
const progressPercent = calcCourseProgressPercent({
lessonsCompleted: progress.lessonsCompleted,
totalLessons: progress.lessonsCount,
exercisesCompleted: progress.exercisesCompleted,
exercisesCount: progress.exercisesCount
});
const now = new Date();
const meetsThreshold = progressPercent >= threshold;
const withinDeadline = isBeforeOrEqualDeadline(certificate.deadline, now);
const hasContent = progress.lessonsCount + progress.exercisesCount > 0;
const blockers: CertificationBlocker[] = [];
if (!withinDeadline) {
blockers.push({ code: 'CERT_DEADLINE_PASSED' });
}
if (!meetsThreshold) {
blockers.push({
code: 'CERT_PROGRESS',
params: { current: progressPercent, required: threshold }
});
}
if (!hasContent) {
blockers.push({ code: 'CERT_NO_CONTENT' });
}
const finalEval = await evaluateFinalExerciseRule({
courseId,
requiredExerciseId: certificate.requiredExerciseId,
minScorePercent: certificate.exerciseMinScorePercent,
groupMemberId: progress.groupMemberId
});
blockers.push(...finalEval.blockers);
const eligibleForCertificate =
progress.roleId === ROLE.STUDENT &&
!!progress.groupMemberId &&
meetsThreshold &&
hasContent &&
withinDeadline &&
finalEval.meetsFinalExerciseRule;
return {
progressPercent,
certificationThreshold: threshold,
meetsThreshold,
eligibleForCertificate,
certificateEarnedAt: progress.certificateEarnedAt,
blockers,
meetsFinalExerciseRule: finalEval.meetsFinalExerciseRule
};
}
/**
* Evaluates certification eligibility. On first eligibility, persists
* `certificateEarnedAt` and fires the completion email (non-blocking).
*/
export async function evaluateCourseCertification(
courseId: string,
profileId: string
): Promise<CourseCompletionEvaluation & { isNewCompletion: boolean }> {
const [progress, courseRow] = await Promise.all([
getCourseProgressQuery(courseId, profileId),
getCourseCertificationRow(courseId)
]);
if (!courseRow) {
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
}
const evaluation = await buildCertificationEvaluation(courseId, profileId, courseRow, progress);
let certificateEarnedAt = evaluation.certificateEarnedAt;
let isNewCompletion = false;
if (evaluation.eligibleForCertificate && !certificateEarnedAt && progress.groupMemberId) {
const earnedAt = new Date().toISOString();
certificateEarnedAt = earnedAt;
isNewCompletion = true;
scheduleCertificationCompletionWork({
courseId,
profileId,
groupMemberId: progress.groupMemberId,
courseRow,
earnedAt
});
}
return { ...evaluation, certificateEarnedAt, isNewCompletion };
}
/**
* Server-side guard for PDF certificate download (must match dashboard eligibility).
*/
export async function assertCertificateDownloadAllowed(courseId: string, profileId: string): Promise<void> {
const evaluation = await evaluateCourseCertification(courseId, profileId);
const [courseMeta] = await db
.select({
certificate: schema.course.certificate
})
.from(schema.course)
.where(eq(schema.course.id, courseId))
.limit(1);
if (!courseMeta?.certificate?.isDownloadable) {
throw new AppError('Certificate download is not enabled for this course', ErrorCodes.VALIDATION_ERROR, 403);
}
const hasEarnedPreviously = evaluation.certificateEarnedAt != null;
const canDownload = hasEarnedPreviously || evaluation.eligibleForCertificate;
if (!canDownload) {
throw new AppError('Course completion requirements not met', ErrorCodes.VALIDATION_ERROR, 403);
}
}
+62 -5
View File
@@ -1,4 +1,5 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { sanitizeHtml, sanitizeOptionalHtml, sanitizeUnknownStrings } from '@api/utils/sanitize-html';
import { addGroupMember, createGroup } from '@cio/db/queries/group';
import {
createCourseNewsfeed,
@@ -20,6 +21,7 @@ import {
import { ContentType, ROLE } from '@cio/utils/constants';
import type { TCourse } from '@cio/db/types';
import { db } from '@cio/db/drizzle';
import { exerciseBelongsToCourse } from '@cio/db/queries/course/certification-exercise';
import { updateExercisesSectionId } from '@cio/db/queries/exercise/exercise';
import { getProfileById } from '@cio/db/queries/auth';
import { buildCourseContent, calcPercentageWithRounding, formatLastSeen, type CourseContent } from './utils';
@@ -27,6 +29,42 @@ import { buildCourseContent, calcPercentageWithRounding, formatLastSeen, type Co
const DEFAULT_CONTENT_GROUPING = true;
const DEFAULT_SECTION_TITLE = 'First Section [edit me]';
function sanitizeCourseMetadata(metadata: TCourse['metadata'] | undefined) {
if (!metadata) return metadata;
return {
...metadata,
description: sanitizeOptionalHtml(metadata.description),
goals: sanitizeOptionalHtml(metadata.goals),
requirements: sanitizeOptionalHtml(metadata.requirements),
reward: metadata.reward
? {
...metadata.reward,
description: sanitizeHtml(metadata.reward.description)
}
: metadata.reward,
instructor: metadata.instructor
? {
...metadata.instructor,
description: sanitizeHtml(metadata.instructor.description)
}
: metadata.instructor,
reviews: metadata.reviews?.map((review) => ({
...review,
description: sanitizeHtml(review.description)
}))
};
}
function sanitizeCourseCertificate(certificate: TCourse['certificate'] | undefined) {
if (!certificate) return certificate;
return {
...certificate,
emailMessage: sanitizeOptionalHtml(certificate.emailMessage)
};
}
/**
* Gets a course by ID or slug with all related data
* @param courseId Course ID (optional if slug provided)
@@ -93,11 +131,13 @@ export async function createCourse(
data: { title: string; description: string; type: 'LIVE_CLASS' | 'SELF_PACED'; organizationId: string }
): Promise<{ course: TCourse; groupId: string; memberId: string }> {
try {
const description = sanitizeHtml(data.description);
const result = await db.transaction(async () => {
// 1. Create group
const [newGroup] = await createGroup({
name: data.title,
description: data.description,
description,
organizationId: data.organizationId
});
@@ -108,7 +148,7 @@ export async function createCourse(
// 2. Create course with group_id
const [newCourse] = await createCourseQuery({
title: data.title,
description: data.description,
description,
type: data.type,
groupId: newGroup.id
});
@@ -130,8 +170,8 @@ export async function createCourse(
// 4. Add default news feed
await createCourseNewsfeed({
content: `<h2>Welcome to this course 🎉&nbsp;</h2>
<p>Thank you for joining this course and I hope you get the best out of it.</p>`,
content: sanitizeHtml(`<h2>Welcome to this course 🎉&nbsp;</h2>
<p>Thank you for joining this course and I hope you get the best out of it.</p>`),
courseId: newCourse.id,
isPinned: true,
authorId: newMember.id
@@ -166,7 +206,24 @@ export async function createCourse(
*/
export async function updateCourse(courseId: string, data: Partial<TCourse>) {
try {
const updated = await updateCourseQuery(courseId, data);
const sanitizedData: Partial<TCourse> = {
...data,
description: sanitizeOptionalHtml(data.description),
overview: sanitizeOptionalHtml(data.overview),
metadata: sanitizeCourseMetadata(data.metadata),
certificate: sanitizeCourseCertificate(data.certificate),
logo: data.logo,
slug: data.slug
};
if (data.certificate?.requiredExerciseId) {
const ok = await exerciseBelongsToCourse(data.certificate.requiredExerciseId, courseId);
if (!ok) {
throw new AppError('Certification exercise must belong to this course', ErrorCodes.VALIDATION_ERROR, 400);
}
}
const updated = await updateCourseQuery(courseId, sanitizeUnknownStrings(sanitizedData));
if (!updated) {
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
}
+10 -3
View File
@@ -343,15 +343,18 @@ async function sendStudentJoinEmails(input: {
courseId: string;
courseName: string;
orgName: string;
orgSiteName: string | null;
studentId: string;
studentEmail: string;
}) {
try {
const loginUrl = getDashboardBaseUrl(input.orgSiteName ?? undefined);
await sendEmail('studentCourseWelcome', {
to: input.studentEmail,
fields: {
orgName: input.orgName,
courseName: input.courseName
courseName: input.courseName,
loginUrl
},
from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`)
});
@@ -678,6 +681,7 @@ export async function enrollInCourse(
courseId,
courseName: title,
orgName: org.name,
orgSiteName: org.siteName,
studentId: user.id,
studentEmail: normalizedEmail
});
@@ -962,7 +966,8 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
alreadyJoined: true,
courseId: course.id,
courseName: course.title,
orgName: organization.name
orgName: organization.name,
orgSiteName: organization.siteName
};
}
@@ -1061,7 +1066,8 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
alreadyJoined: false,
courseId: course.id,
courseName: course.title,
orgName: organization.name
orgName: organization.name,
orgSiteName: organization.siteName
};
});
@@ -1070,6 +1076,7 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
courseId: result.courseId,
courseName: result.courseName,
orgName: result.orgName,
orgSiteName: result.orgSiteName,
studentId: user.id,
studentEmail: normalizedEmail
});
+3 -1
View File
@@ -62,6 +62,7 @@ export async function addMember(
if (courseOrgData) {
const courseName = courseOrgData.courseTitle || '';
const orgName = courseOrgData.orgName || 'ClassroomIO';
const loginUrl = getDashboardBaseUrl(courseOrgData.orgSiteName ?? undefined);
// Get student profile data
let studentEmail: string | null = null;
@@ -86,7 +87,8 @@ export async function addMember(
to: studentEmail,
fields: {
orgName,
courseName
courseName,
loginUrl
},
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`)
});