Files
classroomio/apps/api/src/services/course/completion.ts
T
2026-04-27 11:40:38 +01:00

277 lines
7.8 KiB
TypeScript

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 complianceMinScore = courseRow.type === 'COMPLIANCE' ? (courseRow.compliance?.passingScore ?? null) : null;
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 ?? complianceMinScore,
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);
}
}