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; }; 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> ): Promise { 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 { 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 { 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); } }