feat: certification rules, content sanitization, handle draft course updates and reset content, better redis logging
This commit is contained in:
@@ -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
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 🎉 </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 🎉 </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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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)`)
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user