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
@@ -2,9 +2,20 @@ import { AppError, ErrorCodes } from '@api/utils/errors';
import { env } from '@api/config/env';
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { createCourse, updateCourse } from '@api/services/course/course';
import { createExercise, getExercise, listExercises, replaceExerciseService } from '@api/services/exercise';
import { createCourseSection, listCourseSections, updateCourseSectionService } from '@api/services/course/section';
import { createLesson, listLessons, updateLessonService } from '@api/services/lesson';
import {
createExercise,
deleteExerciseService,
getExercise,
listExercises,
replaceExerciseService
} from '@api/services/exercise';
import {
createCourseSection,
deleteCourseSectionService,
listCourseSections,
updateCourseSectionService
} from '@api/services/course/section';
import { createLesson, listLessons, updateLessonService, deleteLessonService } from '@api/services/lesson';
import { upsertLessonLanguageService, updateLessonLanguageService } from '@api/services/lesson-language';
import {
createCourseImportDraft,
@@ -203,6 +214,43 @@ function parseStoredDraft(record: TCourseImportDraft): TCourseImportDraftPayload
return ZCourseImportDraftPayload.parse(record.draft);
}
async function reserveDraftPublishedCourseId(
orgId: string,
draftId: string,
draft: TCourseImportDraftPayload,
existingSummary: TCourseImportDraft['summary'],
courseId: string
) {
await updateCourseImportDraft(orgId, draftId, {
publishedCourseId: courseId,
summary: {
...(existingSummary ?? {}),
...buildDraftSummary(draft),
publishedCourseId: courseId
}
});
}
async function resetCourseContentForDraftRetry(courseId: string) {
const [exercises, lessons, sections] = await Promise.all([
listExercises(courseId),
listLessons(courseId),
listCourseSections(courseId)
]);
for (const exercise of exercises) {
await deleteExerciseService(exercise.id);
}
for (const lesson of lessons) {
await deleteLessonService(lesson.id);
}
for (const section of sections) {
await deleteCourseSectionService(section.id);
}
}
async function assertCourseBelongsToOrganization(orgId: string, courseId: string) {
const courseOrgId = await getCourseOrganizationId(courseId);
@@ -665,6 +713,14 @@ export async function publishCourseImportDraftService(
const courseDescription = overrides.description ?? draft.course.description;
const courseType = overrides.type ?? draft.course.type;
const courseMetadata = overrides.metadata ?? draft.course.metadata;
const courseUpdateFields: Partial<TCourse> = {
title: courseTitle,
description: courseDescription,
type: courseType
};
if (courseMetadata) {
courseUpdateFields.metadata = courseMetadata;
}
const sectionExternalIds = new Set(draft.sections.map((section) => section.externalId));
draft.lessons.forEach((lesson) => {
@@ -673,17 +729,31 @@ export async function publishCourseImportDraftService(
}
});
const courseResult = await createCourse(profileId, {
title: courseTitle,
description: courseDescription,
type: courseType,
organizationId: orgId
});
let courseId = existing.publishedCourseId;
const courseId = courseResult.course.id;
if (courseId) {
const [existingCourse] = await getCourseById(courseId);
if (!existingCourse || existingCourse.status === 'DELETED') {
courseId = null;
} else {
await updateCourse(courseId, courseUpdateFields);
await resetCourseContentForDraftRetry(courseId);
}
}
if (courseMetadata) {
await updateCourse(courseId, { metadata: courseMetadata });
if (!courseId) {
const courseResult = await createCourse(profileId, {
title: courseTitle,
description: courseDescription,
type: courseType,
organizationId: orgId
});
courseId = courseResult.course.id;
await reserveDraftPublishedCourseId(orgId, draftId, draft, existing.summary, courseId);
if (courseMetadata) {
await updateCourse(courseId, { metadata: courseMetadata });
}
}
const bannerImageUrl = await resolvePublishBannerImage(courseTitle, overrides);
+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)`)
});
+51 -19
View File
@@ -1,4 +1,5 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { sanitizeHtml, sanitizeOptionalHtml, sanitizeUnknownStrings } from '@api/utils/sanitize-html';
import type { TExercise, TExerciseTemplate, TNewOption, TNewQuestion } from '@cio/db/types';
import type { TExerciseCreate, TExerciseUpdate } from '@cio/utils/validation/exercise';
import {
@@ -40,6 +41,29 @@ type ExerciseWithCompletion = TExercise & {
isComplete: boolean;
};
function sanitizeExerciseQuestions(questions: TExerciseCreate['questions'] | TExerciseUpdate['questions']) {
return questions?.map((question) => ({
...question,
question: sanitizeHtml(question.question),
settings: question.settings ? sanitizeUnknownStrings(question.settings) : question.settings,
options: question.options?.map((option) => ({
...option,
label: sanitizeHtml(option.label),
settings: option.settings ? sanitizeUnknownStrings(option.settings) : option.settings
}))
}));
}
function sanitizeExercisePayload(data: TExerciseCreate): TExerciseCreate;
function sanitizeExercisePayload(data: TExerciseUpdate): TExerciseUpdate;
function sanitizeExercisePayload(data: TExerciseCreate | TExerciseUpdate): TExerciseCreate | TExerciseUpdate {
return {
...data,
description: sanitizeOptionalHtml(data.description),
questions: sanitizeExerciseQuestions(data.questions)
};
}
/**
* Creates a new exercise with optional questions and options
* @param data Exercise creation data
@@ -47,14 +71,16 @@ type ExerciseWithCompletion = TExercise & {
*/
export async function createExercise(data: TExerciseCreate): Promise<TExercise> {
try {
const sanitizedData = sanitizeExercisePayload(data);
const exerciseData = {
title: data.title,
description: data.description ?? null,
lessonId: data.lessonId || null,
courseId: data.courseId,
sectionId: data.sectionId || null,
order: data.order ?? null,
dueBy: data.dueBy || null
title: sanitizedData.title,
description: sanitizedData.description ?? null,
lessonId: sanitizedData.lessonId || null,
courseId: sanitizedData.courseId,
sectionId: sanitizedData.sectionId || null,
order: sanitizedData.order ?? null,
dueBy: sanitizedData.dueBy || null
};
const [exercise] = await createExercises([exerciseData]);
@@ -63,10 +89,10 @@ export async function createExercise(data: TExerciseCreate): Promise<TExercise>
}
// Create questions and options if provided
if (data.questions && data.questions.length > 0) {
if (sanitizedData.questions && sanitizedData.questions.length > 0) {
await db.transaction(async (tx) => {
const txClient = tx as DbOrTxClient;
const questionsData: TNewQuestion[] = data.questions!.map((q) => ({
const questionsData: TNewQuestion[] = sanitizedData.questions!.map((q) => ({
exerciseId: exercise.id,
title: q.question,
questionTypeId: q.questionTypeId || 1,
@@ -78,7 +104,7 @@ export async function createExercise(data: TExerciseCreate): Promise<TExercise>
const questions = await createQuestions(questionsData, txClient);
// Create options for each question
const optionsPromises = data.questions!.map(async (q, index) => {
const optionsPromises = sanitizedData.questions!.map(async (q, index) => {
const question = questions[index];
if (!question) return;
@@ -165,11 +191,13 @@ export async function getExercise(
*/
export async function updateExerciseService(exerciseId: string, data: TExerciseUpdate): Promise<TExercise> {
try {
const sanitizedData = sanitizeExercisePayload(data);
return await db.transaction(async (tx) => {
const txClient = tx as DbOrTxClient;
// Update exercise basic fields only if they changed
const exerciseUpdate = buildExerciseUpdateFields(data);
const exerciseUpdate = buildExerciseUpdateFields(sanitizedData);
if (Object.keys(exerciseUpdate).length > 0) {
const updated = await updateExercise(exerciseId, exerciseUpdate, txClient);
if (!updated) {
@@ -178,7 +206,7 @@ export async function updateExerciseService(exerciseId: string, data: TExerciseU
}
// Process questions - only update what changed
if (data.questions && data.questions.length > 0) {
if (sanitizedData.questions && sanitizedData.questions.length > 0) {
// Query current state from DB
const { questions: currentQuestions, options: currentOptions } = await fetchQuestionsAndOptions(
exerciseId,
@@ -192,7 +220,7 @@ export async function updateExerciseService(exerciseId: string, data: TExerciseU
}));
// Compute diff between current DB state and incoming data
const diff = computeExerciseDiff(currentQuestionsWithOptions, data.questions);
const diff = computeExerciseDiff(currentQuestionsWithOptions, sanitizedData.questions);
// Sync option id sequence so next inserts get ids > max(id) (avoids option_pkey duplicate)
if (
@@ -240,10 +268,12 @@ export async function updateExerciseService(exerciseId: string, data: TExerciseU
*/
export async function replaceExerciseService(exerciseId: string, data: TExerciseUpdate): Promise<TExercise> {
try {
const sanitizedData = sanitizeExercisePayload(data);
return await db.transaction(async (tx) => {
const txClient = tx as DbOrTxClient;
const exerciseUpdate = buildExerciseUpdateFields(data);
const exerciseUpdate = buildExerciseUpdateFields(sanitizedData);
if (Object.keys(exerciseUpdate).length > 0) {
const updated = await updateExercise(exerciseId, exerciseUpdate, txClient);
if (!updated) {
@@ -260,7 +290,7 @@ export async function replaceExerciseService(exerciseId: string, data: TExercise
await deleteQuestionsByIds(currentQuestionIds, txClient);
}
const replacementQuestions = data.questions ?? [];
const replacementQuestions = sanitizedData.questions ?? [];
const hasReplacementOptions = replacementQuestions.some((question) => (question.options?.length ?? 0) > 0);
if (replacementQuestions.length > 0) {
@@ -389,14 +419,16 @@ export async function createExerciseFromTemplate(
const txClient = tx as DbOrTxClient;
for (const question of questions) {
const { title, question_type, options, order, points } = question;
const questionSettings = (question as { settings?: Record<string, unknown> }).settings ?? {};
const questionSettings = sanitizeUnknownStrings(
(question as { settings?: Record<string, unknown> }).settings ?? {}
);
// Create question
const [newQuestion] = await createQuestions(
[
{
exerciseId: exercise.id,
title,
title: sanitizeHtml(title),
questionTypeId: question_type.id,
points: points || 0,
order: order || 0,
@@ -415,9 +447,9 @@ export async function createExerciseFromTemplate(
// value column is UUID (default gen_random_uuid()); label is the display text only
const optionsData: TNewOption[] = options.map((opt) => ({
questionId: newQuestion.id,
label: opt.label || '',
label: sanitizeHtml(opt.label || ''),
isCorrect: opt.is_correct || false,
settings: (opt as { settings?: Record<string, unknown> }).settings ?? {}
settings: sanitizeUnknownStrings((opt as { settings?: Record<string, unknown> }).settings ?? {})
}));
await createOptions(optionsData, txClient);
+1
View File
@@ -79,6 +79,7 @@ export function buildExerciseUpdateFields(data: TExerciseUpdate): Partial<TExerc
if (data.order !== undefined) exerciseUpdate.order = data.order;
if (data.isUnlocked !== undefined) exerciseUpdate.isUnlocked = data.isUnlocked;
if (data.dueBy !== undefined) exerciseUpdate.dueBy = data.dueBy || null;
if (data.allowMultipleAttempts !== undefined) exerciseUpdate.allowMultipleAttempts = data.allowMultipleAttempts;
return exerciseUpdate;
}
+6 -1
View File
@@ -1,4 +1,5 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { sanitizeOptionalHtml } from '@api/utils/sanitize-html';
import type { TLessonLanguage, TLocale, TNewLessonLanguage } from '@db/types';
import {
getLessonLanguageByLessonIdAndLocale,
@@ -65,6 +66,7 @@ export async function upsertLessonLanguageService(
const language = await upsertLessonLanguage({
...data,
content: sanitizeOptionalHtml(data.content),
lessonId
});
@@ -105,7 +107,10 @@ export async function updateLessonLanguageService(
throw new AppError('Lesson language not found', ErrorCodes.LESSON_LANGUAGE_NOT_FOUND, 404);
}
const language = await updateLessonLanguage(lessonId, locale, data);
const language = await updateLessonLanguage(lessonId, locale, {
...data,
content: sanitizeOptionalHtml(data.content)
});
return language;
} catch (error) {
if (error instanceof AppError) {
+3 -2
View File
@@ -1,4 +1,5 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { sanitizeHtml } from '@api/utils/sanitize-html';
import type { TLesson, TNewLessonComment, TNewLessonCompletion } from '@cio/db/types';
import type { TLessonCreate, TLessonReorder, TLessonUpdate } from '@cio/utils/validation/lesson';
import {
@@ -230,7 +231,7 @@ export async function createLessonCommentService(lessonId: string, groupMemberId
const commentData: TNewLessonComment = {
lessonId,
groupmemberId: groupMemberId,
comment
comment: sanitizeHtml(comment)
};
return await createLessonComment(commentData);
@@ -252,7 +253,7 @@ export async function createLessonCommentService(lessonId: string, groupMemberId
*/
export async function updateLessonCommentService(commentId: number, comment: string) {
try {
const updated = await updateLessonComment(commentId, comment);
const updated = await updateLessonComment(commentId, sanitizeHtml(comment));
if (!updated) {
throw new AppError('Comment not found', ErrorCodes.LESSON_COMMENT_UPDATE_FAILED, 404);
}
+13 -5
View File
@@ -1,4 +1,5 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { sanitizeHtml } from '@api/utils/sanitize-html';
import type { TCourseNewsfeed, TNewCourseNewsfeed, TNewCourseNewsfeedComment } from '@cio/db/types';
import type { TNewsfeedCreate, TNewsfeedUpdate } from '@cio/utils/validation/newsfeed';
import {
@@ -93,10 +94,12 @@ export async function createNewsfeedService(
data: TNewsfeedCreate
): Promise<TCourseNewsfeed> {
try {
const sanitizedContent = sanitizeHtml(data.content);
const newsfeedData: TNewCourseNewsfeed = {
courseId,
authorId,
content: data.content,
content: sanitizedContent,
isPinned: data.isPinned || false
};
@@ -125,7 +128,10 @@ export async function createNewsfeedService(
*/
export async function updateNewsfeedService(feedId: string, data: TNewsfeedUpdate): Promise<TCourseNewsfeed> {
try {
const updated = await updateNewsfeed(feedId, data);
const updated = await updateNewsfeed(feedId, {
...data,
content: data.content === undefined ? undefined : sanitizeHtml(data.content)
});
if (!updated) {
// If no row was updated, it usually means the feed doesn't exist.
throw new AppError('Newsfeed item not found', ErrorCodes.INTERNAL_ERROR, 404);
@@ -256,16 +262,18 @@ export async function getNewsfeedCommentsService(feedId: string, options: { curs
*/
export async function createNewsfeedCommentService(feedId: string, authorId: string, content: string) {
try {
const sanitizedContent = sanitizeHtml(content);
const commentData: TNewCourseNewsfeedComment = {
courseNewsfeedId: feedId,
authorId,
content
content: sanitizedContent
};
const comment = await createNewsfeedComment(commentData);
// Send email notification to feed author (async, don't wait)
sendNewsfeedCommentEmail(feedId, content).catch((error) => {
sendNewsfeedCommentEmail(feedId, sanitizedContent).catch((error) => {
console.error('Failed to send newsfeed comment email:', error);
});
@@ -287,7 +295,7 @@ export async function createNewsfeedCommentService(feedId: string, authorId: str
*/
export async function updateNewsfeedCommentService(commentId: number, content: string) {
try {
const updated = await updateNewsfeedComment(commentId, content);
const updated = await updateNewsfeedComment(commentId, sanitizeHtml(content));
if (!updated) {
throw new AppError('Comment not found', ErrorCodes.NEWSFEED_COMMENT_UPDATE_FAILED, 404);
}
+64 -12
View File
@@ -1,6 +1,7 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import type { TNewOrganizationPlan, TOrganization, TOrganizationPlan } from '@db/types';
import {
deleteOrganizationAudienceMember,
cancelOrganizationPlan,
createOrganizationPlan,
deleteOrganizationMember,
@@ -34,7 +35,8 @@ import { createOrganizationWithOwner } from '@api/services/onboarding';
import { getProfileById } from '@cio/db/queries/auth';
import { inviteTeamMembers as inviteTeamMembersSecure } from './organization/invite';
import { deriveAudienceMemberStatus } from '@api/utils/audience-member-status';
import type { OrgAudienceMember } from '@api/types/org';
import type { OrgAudienceMember, OrgAudiencePagination, OrgAudienceQuery } from '@api/types/org';
import type { TGetAudienceQuery } from '@cio/utils/validation/organization';
/**
* Creates a new organization with the current user as owner
@@ -121,24 +123,43 @@ export async function getOrgTeam(orgId: string) {
* @param orgId - The organization ID
* @returns Audience members with invite status for profile-less rows
*/
export async function getOrgAudience(orgId: string): Promise<OrgAudienceMember[]> {
export async function getOrgAudience(
orgId: string,
query: TGetAudienceQuery
): Promise<{ items: OrgAudienceMember[]; pagination: OrgAudiencePagination; query: OrgAudienceQuery }> {
try {
const audience = await getOrganizationAudience(orgId);
const audienceResult = await getOrganizationAudience(orgId, query);
const audience = audienceResult.items;
const emailsWithoutProfile = audience.filter((m) => !m.profileId && m.email).map((m) => m.email.toLowerCase());
const invites = await getLatestOrgInvitesByEmails(orgId, emailsWithoutProfile);
const inviteByEmail = new Map(invites.map((i) => [i.email.toLowerCase(), i]));
return audience.map(
(member): OrgAudienceMember => ({
...member,
status: deriveAudienceMemberStatus(
member.profileId,
member.email ? inviteByEmail.get(member.email.toLowerCase()) : undefined
)
})
);
return {
items: audience.map(
(member): OrgAudienceMember => ({
...member,
status: deriveAudienceMemberStatus(
member.profileId,
member.email ? inviteByEmail.get(member.email.toLowerCase()) : undefined
)
})
),
pagination: {
page: audienceResult.page,
limit: audienceResult.limit,
total: audienceResult.total,
totalPages: audienceResult.totalPages
},
query: {
page: audienceResult.page,
limit: audienceResult.limit,
search: query.search,
sortBy: query.sortBy,
sortOrder: query.sortOrder
}
};
} catch (error) {
throw new AppError(
error instanceof Error ? error.message : 'Failed to fetch organization audience',
@@ -148,6 +169,37 @@ export async function getOrgAudience(orgId: string): Promise<OrgAudienceMember[]
}
}
/**
* Removes a student audience member from an organization
* @param orgId - The organization ID
* @param memberId - The student member ID to remove
* @returns Deleted member
*/
export async function removeAudienceMember(orgId: string, memberId: number, removedByProfileId?: string) {
try {
const deleted = await deleteOrganizationAudienceMember(orgId, memberId);
if (!deleted) {
throw new AppError('Audience member not found', ErrorCodes.ORG_AUDIENCE_REMOVE_FAILED, 404);
}
if (deleted.email && removedByProfileId) {
await revokeActiveOrganizationInvitesByEmails(orgId, [deleted.email.toLowerCase()], removedByProfileId);
}
return deleted;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to remove audience member',
ErrorCodes.ORG_AUDIENCE_REMOVE_FAILED,
500
);
}
}
/**
* Gets public courses for an organization (landing page)
* @param siteName - The organization siteName
+234 -123
View File
@@ -7,7 +7,6 @@ import type {
import { addGroupMembers, enrollUsersInCourseGroups, getExistingGroupMembers } from '@cio/db/queries/group';
import { buildEmailFromName, sendEmail } from '@cio/email';
import {
checkEmailsExistInOrg,
createOrganizationInvite,
createOrganizationInviteAudits,
createOrganizationInvites,
@@ -15,6 +14,7 @@ import {
getLatestOrganizationInviteRowByOrgAndEmail,
getOrgMembersByProfileIds,
getOrganizationById,
getOrganizationMembersByNormalizedEmails,
getStudentOrganizationMemberByOrgAndEmail,
hasActiveOrganizationInviteForEmail,
revokeActiveOrganizationInvitesByEmails
@@ -108,52 +108,9 @@ function getNormalizedRecipients(recipientCsv: string): {
return { valid, invalid, duplicates };
}
export async function importAudienceMembers(orgId: string, data: TImportAudienceMembers, invitedByProfileId: string) {
const organization = await getOrganizationById(orgId);
if (!organization || !organization.siteName) {
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
}
const recipients = getNormalizedRecipients(data.recipientCsv);
if (recipients.invalid.length > 0) {
throw new AppError(
`Invalid emails found: ${recipients.invalid.slice(0, 5).join(', ')}`,
ErrorCodes.VALIDATION_ERROR,
400,
'recipientCsv'
);
}
if (recipients.valid.length === 0) {
throw new AppError('No valid emails provided', ErrorCodes.VALIDATION_ERROR, 400, 'recipientCsv');
}
const existingEmails = await checkEmailsExistInOrg(orgId, recipients.valid);
const emailsToImport = recipients.valid.filter((email) => !existingEmails.includes(email));
if (emailsToImport.length === 0) {
return {
imported: 0,
skipped: existingEmails.length,
duplicates: recipients.duplicates.length,
emailsSent: 0,
emailsFailed: 0
};
}
await createOrganizationMembers(
emailsToImport.map((email) => ({
organizationId: orgId,
email,
roleId: ROLE.STUDENT,
verified: false
}))
);
async function resolveCourseIdsAndNamesForImport(orgId: string, data: TImportAudienceMembers) {
let courseIds: string[] = [];
let courseNames: string[] = [];
if (data.allCourses) {
const courses = await getOrgCourses({ orgId });
courseIds = courses.map((c) => c.id);
@@ -163,25 +120,108 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
courseIds = courses.map((c) => c.id);
courseNames = courses.map((c) => c.title).filter(Boolean);
}
if (courseIds.length > 0) {
const courseGroupMappings = await getCourseGroupIds(courseIds);
const validGroupIds = courseGroupMappings.map((m) => m.groupId).filter(Boolean) as string[];
return { courseIds, courseNames };
}
if (validGroupIds.length > 0) {
const profiles = await getProfilesByEmails(emailsToImport);
if (profiles.length > 0) {
const users = profiles.map((p) => ({ profileId: p.id, email: p.email ?? undefined }));
await enrollUsersInCourseGroups(validGroupIds, users, ROLE.STUDENT);
}
}
async function enrollAudienceStudentProfilesInCourses(
orgId: string,
organization: NonNullable<Awaited<ReturnType<typeof getOrganizationById>>>,
profileIds: string[],
courseIds: string[],
shouldSendEmail: boolean
): Promise<{ assigned: number; alreadyEnrolled: number; emailsSent: number }> {
if (courseIds.length === 0 || profileIds.length === 0) {
return { assigned: 0, alreadyEnrolled: 0, emailsSent: 0 };
}
await revokeActiveOrganizationInvitesByEmails(orgId, emailsToImport, invitedByProfileId);
const uniqueProfileIds = [...new Set(profileIds)];
const orgMembers = await getOrgMembersByProfileIds(orgId, uniqueProfileIds);
const studentMembers = orgMembers.filter((m) => m.profileId && m.roleId === ROLE.STUDENT);
const validProfileIds = new Set(studentMembers.map((m) => m.profileId!));
const profileEmailMap = new Map(studentMembers.filter((m) => m.profileId).map((m) => [m.profileId!, m.email ?? '']));
const courseGroups = await getOrgCourseGroups(orgId, courseIds);
if (courseGroups.length === 0) {
return { assigned: 0, alreadyEnrolled: 0, emailsSent: 0 };
}
const validGroupIds = courseGroups.map((cg) => cg.groupId).filter(Boolean) as string[];
const courseTitleByGroupId = new Map(courseGroups.map((cg) => [cg.groupId, cg.courseTitle]));
const validProfiles = uniqueProfileIds.filter((id) => validProfileIds.has(id));
const pairs = validProfiles.flatMap((profileId) => validGroupIds.map((groupId) => ({ groupId, profileId })));
const existingSet = await getExistingGroupMembers(pairs);
const toInsert = pairs.filter((p) => !existingSet.has(`${p.groupId}:${p.profileId}`));
const alreadyEnrolled = pairs.length - toInsert.length;
if (toInsert.length > 0) {
await addGroupMembers(
toInsert.map((p) => ({
groupId: p.groupId,
roleId: ROLE.STUDENT,
profileId: p.profileId,
email: profileEmailMap.get(p.profileId) || undefined
}))
);
}
let emailsSent = 0;
const loginUrl = getDashboardBaseUrl(organization.siteName ?? undefined);
if (shouldSendEmail && toInsert.length > 0) {
const emailPromises = toInsert
.filter((p) => profileEmailMap.get(p.profileId))
.map(async (p) => {
const email = profileEmailMap.get(p.profileId)!;
try {
await sendEmail('studentCourseWelcome', {
to: email,
fields: {
orgName: organization.name,
courseName: courseTitleByGroupId.get(p.groupId) || 'Course',
loginUrl
},
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
});
emailsSent++;
} catch (emailError) {
console.error(`enrollAudienceStudentProfilesInCourses email error for ${email}:`, emailError);
}
});
await Promise.all(emailPromises);
}
return {
assigned: toInsert.length,
alreadyEnrolled,
emailsSent
};
}
async function createStudentOrgInvitesAndSendEmails(input: {
orgId: string;
organization: NonNullable<Awaited<ReturnType<typeof getOrganizationById>>>;
emails: string[];
courseIds: string[];
courseNamesLabel: string | undefined;
invitedByProfileId: string;
shouldSendEmail: boolean;
}): Promise<{ created: number; emailsSent: number; emailsFailed: number }> {
const { orgId, organization, emails, courseIds, courseNamesLabel, invitedByProfileId, shouldSendEmail } = input;
if (emails.length === 0) {
return { created: 0, emailsSent: 0, emailsFailed: 0 };
}
await revokeActiveOrganizationInvitesByEmails(orgId, emails, invitedByProfileId);
const expiresAt = new Date(Date.now() + ORG_INVITE_EXPIRY_MS).toISOString();
const courseNamesLabel = courseNames.length > 0 ? courseNames.join(', ') : undefined;
const inviteInputs = emailsToImport.map((email) => {
const inviteInputs = emails.map((email) => {
const token = generateToken();
return {
email,
@@ -207,7 +247,7 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
const tokenByEmail = new Map(inviteInputs.map((i) => [i.email.toLowerCase(), i.token]));
await createOrganizationInviteAudits(
emailsToImport.map((email) => {
emails.map((email) => {
const invite = inviteByEmail.get(email)!;
return {
inviteId: invite.id,
@@ -230,8 +270,8 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
let emailsSent = 0;
let emailsFailed = 0;
if (data.sendEmail) {
const emailOutcomes = await mapWithConcurrency(emailsToImport, EMAIL_SEND_CONCURRENCY, async (email) => {
if (shouldSendEmail) {
const emailOutcomes = await mapWithConcurrency(emails, EMAIL_SEND_CONCURRENCY, async (email) => {
const invite = inviteByEmail.get(email)!;
const token = tokenByEmail.get(email)!;
try {
@@ -289,12 +329,140 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
);
}
return { created: emails.length, emailsSent, emailsFailed };
}
export async function importAudienceMembers(orgId: string, data: TImportAudienceMembers, invitedByProfileId: string) {
const organization = await getOrganizationById(orgId);
if (!organization || !organization.siteName) {
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
}
const recipients = getNormalizedRecipients(data.recipientCsv);
if (recipients.invalid.length > 0) {
throw new AppError(
`Invalid emails found: ${recipients.invalid.slice(0, 5).join(', ')}`,
ErrorCodes.VALIDATION_ERROR,
400,
'recipientCsv'
);
}
if (recipients.valid.length === 0) {
throw new AppError('No valid emails provided', ErrorCodes.VALIDATION_ERROR, 400, 'recipientCsv');
}
const memberRows = await getOrganizationMembersByNormalizedEmails(orgId, recipients.valid);
const memberByEmail = new Map(memberRows.map((m) => [m.normalizedEmail, m]));
const newEmails: string[] = [];
const existingStudentProfileIds: string[] = [];
const pendingStudentEmails: string[] = [];
const teamEmails: string[] = [];
for (const email of recipients.valid) {
const m = memberByEmail.get(email);
if (!m) {
newEmails.push(email);
continue;
}
if (m.roleId !== ROLE.STUDENT) {
teamEmails.push(email);
continue;
}
if (!m.profileId) {
pendingStudentEmails.push(email);
continue;
}
existingStudentProfileIds.push(m.profileId);
}
if (teamEmails.length > 0) {
throw new AppError(
`These emails belong to organization staff, not students: ${teamEmails.slice(0, 8).join(', ')}${teamEmails.length > 8 ? '…' : ''}`,
ErrorCodes.VALIDATION_ERROR,
400,
'recipientCsv'
);
}
const { courseIds, courseNames } = await resolveCourseIdsAndNamesForImport(orgId, data);
const courseNamesLabel = courseNames.length > 0 ? courseNames.join(', ') : undefined;
const assignResult = await enrollAudienceStudentProfilesInCourses(
orgId,
organization,
existingStudentProfileIds,
courseIds,
data.sendEmail
);
let imported = 0;
let importEmailsSent = 0;
let importEmailsFailed = 0;
if (newEmails.length > 0) {
await createOrganizationMembers(
newEmails.map((email) => ({
organizationId: orgId,
email,
roleId: ROLE.STUDENT,
verified: false
}))
);
if (courseIds.length > 0) {
const courseGroupMappings = await getCourseGroupIds(courseIds);
const validGroupIds = courseGroupMappings.map((m) => m.groupId).filter(Boolean) as string[];
if (validGroupIds.length > 0) {
const profiles = await getProfilesByEmails(newEmails);
if (profiles.length > 0) {
const users = profiles.map((p) => ({ profileId: p.id, email: p.email ?? undefined }));
await enrollUsersInCourseGroups(validGroupIds, users, ROLE.STUDENT);
}
}
}
const inviteOutcome = await createStudentOrgInvitesAndSendEmails({
orgId,
organization,
emails: newEmails,
courseIds,
courseNamesLabel,
invitedByProfileId,
shouldSendEmail: data.sendEmail
});
imported = newEmails.length;
importEmailsSent = inviteOutcome.emailsSent;
importEmailsFailed = inviteOutcome.emailsFailed;
}
let pendingEmailsSent = 0;
let pendingEmailsFailed = 0;
if (pendingStudentEmails.length > 0) {
const pendingOutcome = await createStudentOrgInvitesAndSendEmails({
orgId,
organization,
emails: pendingStudentEmails,
courseIds,
courseNamesLabel,
invitedByProfileId,
shouldSendEmail: data.sendEmail
});
pendingEmailsSent = pendingOutcome.emailsSent;
pendingEmailsFailed = pendingOutcome.emailsFailed;
}
return {
imported: emailsToImport.length,
skipped: existingEmails.length,
imported,
assigned: assignResult.assigned,
alreadyEnrolledInCourses: assignResult.alreadyEnrolled,
pendingInvitesRenewed: pendingStudentEmails.length,
duplicates: recipients.duplicates.length,
emailsSent,
emailsFailed
emailsSent: assignResult.emailsSent + importEmailsSent + pendingEmailsSent,
emailsFailed: importEmailsFailed + pendingEmailsFailed
};
}
@@ -465,67 +633,10 @@ export async function assignAudienceToCourses(orgId: string, data: TAssignAudien
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
}
const orgMembers = await getOrgMembersByProfileIds(orgId, data.profileIds);
const validProfileIds = new Set(orgMembers.map((m) => m.profileId).filter(Boolean));
const profileEmailMap = new Map(orgMembers.filter((m) => m.profileId).map((m) => [m.profileId!, m.email ?? '']));
const courseGroups = await getOrgCourseGroups(orgId, data.courseIds);
if (courseGroups.length === 0) {
throw new AppError('No valid courses found', ErrorCodes.VALIDATION_ERROR, 400, 'courseIds');
}
const validGroupIds = courseGroups.map((cg) => cg.groupId).filter(Boolean) as string[];
const courseTitleByGroupId = new Map(courseGroups.map((cg) => [cg.groupId, cg.courseTitle]));
const validProfiles = data.profileIds.filter((id) => validProfileIds.has(id));
const pairs = validProfiles.flatMap((profileId) => validGroupIds.map((groupId) => ({ groupId, profileId })));
const existingSet = await getExistingGroupMembers(pairs);
const toInsert = pairs.filter((p) => !existingSet.has(`${p.groupId}:${p.profileId}`));
const alreadyEnrolled = pairs.length - toInsert.length;
if (toInsert.length > 0) {
await addGroupMembers(
toInsert.map((p) => ({
groupId: p.groupId,
roleId: ROLE.STUDENT,
profileId: p.profileId,
email: profileEmailMap.get(p.profileId) || undefined
}))
);
}
let emailsSent = 0;
if (data.sendEmail && toInsert.length > 0) {
const emailPromises = toInsert
.filter((p) => profileEmailMap.get(p.profileId))
.map(async (p) => {
const email = profileEmailMap.get(p.profileId)!;
try {
await sendEmail('studentCourseWelcome', {
to: email,
fields: {
orgName: organization.name,
courseName: courseTitleByGroupId.get(p.groupId) || 'Course'
},
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
});
emailsSent++;
} catch (emailError) {
console.error(`assignAudienceToCourses email error for ${email}:`, emailError);
}
});
await Promise.all(emailPromises);
}
return {
assigned: toInsert.length,
alreadyEnrolled,
emailsSent
};
return enrollAudienceStudentProfilesInCourses(orgId, organization, data.profileIds, data.courseIds, data.sendEmail);
}
+78 -74
View File
@@ -1,7 +1,7 @@
import type { AnswerData, FileUploadAnswerData } from '@cio/question-types';
import { scoreSubmissionAnswers, type ScoreSubmissionResult, validateTextareaAnswer } from '@cio/question-types';
import { AppError, ErrorCodes } from '@api/utils/errors';
import type { TNewSubmission, TSubmission } from '@cio/db/types';
import type { TNewQuestionAnswer, TNewSubmission, TSubmission } from '@cio/db/types';
import type {
TSubmissionAnswerUpdate,
TSubmissionGradesUpdate,
@@ -15,10 +15,11 @@ import {
getSubmissionById,
getSubmissionsByCourseIdWithDetails,
getSubmissionsForGrading,
hasSubmission,
insertQuestionAnswersBatch,
updateQuestionAnswer,
updateSubmission,
updateSubmissionGrades,
upsertQuestionAnswer
updateSubmissionGrades
} from '@cio/db/queries/submission';
import {
fromApiPayload,
@@ -32,7 +33,7 @@ import { getExerciseById, getExerciseWithRelationsOptimized } from '@cio/db/quer
import { getGroupMemberIdByCourseAndProfile, isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group';
import { QUESTION_TYPE_ID_TO_KEY } from '@cio/question-types';
import { db } from '@cio/db/drizzle';
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { generateDocumentDownloadPresignedUrls } from '@api/utils/s3';
@@ -355,27 +356,26 @@ export async function listSubmissionsForGrading(courseId: string) {
timeStyle: 'medium'
}).format(new Date(submission.createdAt!));
// Format answers
const questionByIdAndName: { [id: number]: string } = {};
const questionKeyById: { [id: number]: string } = {};
const questionTypeById: { [id: number]: number } = {};
for (const question of submission.exercise.questions || []) {
questionByIdAndName[question.id] = question.name ?? '';
questionKeyById[question.id] = question.name ? String(question.name) : String(question.id);
questionTypeById[question.id] = question.questionTypeId;
}
const formattedAnswers: { [questionName: string]: AnswerData } = {};
const formattedAnswers: { [questionKey: string]: AnswerData } = {};
const questionAnswerByPoint: { [questionId: number]: number } = {};
for (const answer of submission.answers || []) {
const questionName = questionByIdAndName[answer.questionId];
const questionKey = questionKeyById[answer.questionId];
if (
questionName &&
questionKey &&
answer.answerData &&
typeof answer.answerData === 'object' &&
answer.answerData !== null &&
'type' in answer.answerData
) {
formattedAnswers[questionName] = answer.answerData as AnswerData;
formattedAnswers[questionKey] = answer.answerData as AnswerData;
}
questionAnswerByPoint[answer.questionId] = answer.point || 0;
}
@@ -475,11 +475,15 @@ export async function createSubmissionService(
answers: Array<{ questionId: number; optionId?: number; answer?: string }>
): Promise<TSubmission & { answers?: any[] }> {
try {
const exerciseWithRelations = await getExerciseWithRelationsOptimized(exerciseId);
const [exerciseWithRelations, courseRows] = await Promise.all([
getExerciseWithRelationsOptimized(exerciseId),
getCourseById(courseId)
]);
const course = courseRows[0];
if (!exerciseWithRelations.exercise.allowMultipleAttempts) {
const existing = await getSubmissionsByCourseIdWithDetails(courseId, exerciseId, submittedBy);
if (existing.length > 0) {
const alreadySubmitted = await hasSubmission(exerciseId, submittedBy);
if (alreadySubmitted) {
throw new AppError('This exercise allows only one submission', ErrorCodes.VALIDATION_ERROR, 400);
}
}
@@ -498,7 +502,7 @@ export async function createSubmissionService(
statusId: projectLegacyStatusId(gradingState),
gradingState,
overallStatus,
total: 0 // Will be calculated during grading
total: 0
};
let submission = await createSubmission(submissionData);
@@ -529,51 +533,45 @@ export async function createSubmissionService(
);
const answerByQuestionId = new Map<number, AnswerData>();
const answerRows: TNewQuestionAnswer[] = [];
// Create question answers
if (answers.length > 0) {
await db.transaction(async () => {
for (const ans of answers) {
const question = questionById.get(ans.questionId);
if (!question) continue;
for (const ans of answers) {
const question = questionById.get(ans.questionId);
if (!question) continue;
const answerData = fromApiPayload(
question.questionType,
{ questionId: ans.questionId, optionId: ans.optionId, answer: ans.answer },
question
const answerData = fromApiPayload(
question.questionType,
{ questionId: ans.questionId, optionId: ans.optionId, answer: ans.answer },
question
);
if (!answerData) continue;
if (answerData.type === 'TEXTAREA') {
const validation = validateTextareaAnswer(answerData.text, question);
if (!validation.isValid) {
throw new AppError(
validation.minCharacters !== undefined && validation.maxCharacters !== undefined
? `Paragraph answer must be between ${validation.minCharacters} and ${validation.maxCharacters} characters`
: validation.reason === 'below_min' && validation.minCharacters !== undefined
? `Paragraph answer must be at least ${validation.minCharacters} characters`
: `Paragraph answer must be at most ${validation.maxCharacters} characters`,
ErrorCodes.VALIDATION_ERROR,
400
);
if (!answerData) continue;
if (answerData.type === 'TEXTAREA') {
const validation = validateTextareaAnswer(answerData.text, question);
if (!validation.isValid) {
throw new AppError(
validation.minCharacters !== undefined && validation.maxCharacters !== undefined
? `Paragraph answer must be between ${validation.minCharacters} and ${validation.maxCharacters} characters`
: validation.reason === 'below_min' && validation.minCharacters !== undefined
? `Paragraph answer must be at least ${validation.minCharacters} characters`
: `Paragraph answer must be at most ${validation.maxCharacters} characters`,
ErrorCodes.VALIDATION_ERROR,
400
);
}
}
answerByQuestionId.set(ans.questionId, answerData);
await upsertQuestionAnswer({
submissionId: submission.id,
questionId: ans.questionId,
groupMemberId: submittedBy,
answerData
});
}
}
answerByQuestionId.set(ans.questionId, answerData);
answerRows.push({
submissionId: submission.id,
questionId: ans.questionId,
groupMemberId: submittedBy,
answerData
});
}
const courseRows = await getCourseById(courseId);
const course = courseRows[0];
const shouldAutoGrade =
course?.type === 'SELF_PACED' && overallStatus === 'auto_graded' && exerciseWithRelations.questions.length > 0;
@@ -582,7 +580,7 @@ export async function createSubmissionService(
for (const q of exerciseWithRelations.questions) {
if (q.id == null) continue;
if (!answeredIds.has(q.id)) {
await upsertQuestionAnswer({
answerRows.push({
submissionId: submission.id,
questionId: q.id,
groupMemberId: submittedBy,
@@ -590,7 +588,11 @@ export async function createSubmissionService(
});
}
}
}
const insertedAnswers = answerRows.length > 0 ? await insertQuestionAnswersBatch(answerRows) : [];
if (shouldAutoGrade) {
const dbQuestions = exerciseWithRelations.questions
.filter((q): q is typeof q & { id: number } => q.id != null)
.map((q) => ({
@@ -616,20 +618,28 @@ export async function createSubmissionService(
if (graded) {
submission = graded;
const pointByQuestionId = new Map(
scores.map((s: ScoreSubmissionResult['scores'][number]) => [s.questionId, s.points])
);
const answersWithPoints = insertedAnswers.map((row) => ({
...row,
point: pointByQuestionId.get(row.questionId) ?? row.point
}));
void sendExerciseSubmissionUpdateEmail(courseId, exerciseId, submittedBy).catch((emailError) => {
console.error('Failed to send exercise submission update email:', emailError);
});
const enrichedAnswers =
answersWithPoints.length > 0 ? await enrichFileUploadAnswersArray(answersWithPoints) : answersWithPoints;
return { ...submission, answers: enrichedAnswers };
}
}
// Send email notification to teachers
try {
await sendExerciseSubmissionUpdateEmail(courseId, exerciseId, submittedBy);
} catch (emailError) {
// Log but don't fail the request if email fails
void sendExerciseSubmissionUpdateEmail(courseId, exerciseId, submittedBy).catch((emailError) => {
console.error('Failed to send exercise submission update email:', emailError);
}
if (submission.gradingState === 'completed') {
return await getSubmission(submission.id);
}
});
return submission;
} catch (error) {
@@ -679,14 +689,10 @@ export async function updateSubmissionService(submissionId: string, data: TSubmi
throw new AppError('Failed to update submission', ErrorCodes.INTERNAL_ERROR, 500);
}
// Send email notification if status changed
if (statusChanged) {
try {
await sendSubmissionUpdateEmail(submissionId, nextLegacyStatusId);
} catch (emailError) {
// Log but don't fail the request if email fails
void sendSubmissionUpdateEmail(submissionId, nextLegacyStatusId).catch((emailError) => {
console.error('Failed to send submission update email:', emailError);
}
});
}
return updated;
@@ -782,11 +788,9 @@ export async function updateSubmissionGradesBatch(
}
if (statusChanged) {
try {
await sendSubmissionUpdateEmail(submissionId, targetStatusId);
} catch (emailError) {
void sendSubmissionUpdateEmail(submissionId, targetStatusId).catch((emailError) => {
console.error('Failed to send submission update email:', emailError);
}
});
}
return updated;