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 -3
View File
@@ -9,7 +9,7 @@ import type { MiddlewareHandler } from 'hono';
import { RedisRateLimiter } from '@api/utils/redis/limiter';
import { userKeyGenerator } from '../utils/redis/key-generators';
import { env } from '@api/config/env';
import { redis } from '@api/utils/redis/redis';
import { logRedisUnavailableOnce, redis } from '@api/utils/redis/redis';
export interface RateLimiterOptions {
windowMs?: number;
@@ -80,8 +80,7 @@ export const createRateLimiter = (options: RateLimiterOptions = {}): MiddlewareH
await next();
} catch (error) {
console.error('Rate limiter error:', error);
// If Redis is down, allow the request but log the error
logRedisUnavailableOnce('Redis rate limiter unavailable, allowing requests without rate limiting', error);
await next();
}
};
+32
View File
@@ -26,6 +26,7 @@ import {
getCourseProgress,
updateCourse
} from '@api/services/course/course';
import { assertCertificateDownloadAllowed, evaluateCourseCertification } from '@api/services/course/completion';
import { getCourseTags, replaceCourseTags } from '@api/services/tag';
import { Hono } from '@api/utils/hono';
@@ -393,6 +394,33 @@ export const courseRouter = new Hono()
}
}
)
/**
* GET /course/:courseId/certification-evaluation
* Full certification eligibility for the current user (blockers for UI).
*/
.get(
'/:courseId/certification-evaluation',
authMiddleware,
courseMemberMiddleware,
zValidator('param', ZCourseProgressParam),
async (c) => {
try {
const { courseId } = c.req.valid('param');
const user = c.get('user')!;
const data = await evaluateCourseCertification(courseId, user.id);
return c.json(
{
success: true,
data
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to evaluate certification');
}
}
)
/**
* GET /course/:courseId/analytics
* Gets course analytics including student progress, completion rates, and grades
@@ -427,6 +455,10 @@ export const courseRouter = new Hono()
zValidator('param', ZCourseDownloadParam),
zValidator('json', ZCertificateDownload),
async (c) => {
const { courseId } = c.req.valid('param');
const user = c.get('user')!;
await assertCertificateDownloadAllowed(courseId, user.id);
const validatedData = c.req.valid('json');
const result = await generateCertificate(validatedData);
@@ -4,6 +4,7 @@ import {
ZCancelOrgPlan,
ZCreateOrgPlan,
ZCreateOrganization,
ZGetAudienceQuery,
ZGetCoursesBySiteName,
ZGetOrgSetup,
ZGetOrganizationCoursesQuery,
@@ -37,6 +38,7 @@ import {
getRecommendedCourses,
getUserAnalytics,
getUserEnrolledCourses,
removeAudienceMember,
removeTeamMember,
updateOrg,
updateOrgPlan
@@ -178,15 +180,18 @@ export const organizationRouter = new Hono()
* Gets organization audience (students)
* Requires authentication
*/
.get('/audience', authMiddleware, orgTeamMemberMiddleware, async (c) => {
.get('/audience', authMiddleware, orgTeamMemberMiddleware, zValidator('query', ZGetAudienceQuery), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const audience = await getOrgAudience(orgId);
const query = c.req.valid('query');
const audience = await getOrgAudience(orgId, query);
return c.json(
{
success: true,
data: audience
data: audience.items,
pagination: audience.pagination,
query: audience.query
},
200
);
@@ -194,6 +199,35 @@ export const organizationRouter = new Hono()
return handleError(c, error, 'Failed to fetch organization audience');
}
})
/**
* DELETE /organization/audience/:memberId
* Removes a student from the organization
*/
.delete(
'/audience/:memberId',
authMiddleware,
orgAdminMiddleware,
zValidator('param', ZRemoveTeamMember),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const user = c.get('user')!;
const { memberId } = c.req.valid('param');
const deleted = await removeAudienceMember(orgId, memberId, user.id);
return c.json(
{
success: true,
data: deleted
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to remove audience member');
}
}
)
/**
* POST /organization/audience/resend-invite
* Resends a student org invite email (new token; optional course metadata from last invite)
@@ -604,7 +638,7 @@ export const organizationRouter = new Hono()
.post(
'/audience/assign-courses',
authMiddleware,
orgAdminMiddleware,
orgTeamMemberMiddleware,
zValidator('json', ZAssignAudienceCourses),
async (c) => {
try {
@@ -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;
+16
View File
@@ -1,4 +1,5 @@
import type { AudienceMemberStatus } from '@api/utils/audience-member-status';
import type { TAudienceSortBy, TAudienceSortOrder } from '@cio/utils/validation/organization';
export type OrgAudienceMember = {
id: number;
@@ -9,3 +10,18 @@ export type OrgAudienceMember = {
createdAt: string;
status: AudienceMemberStatus;
};
export type OrgAudiencePagination = {
page: number;
limit: number;
total: number;
totalPages: number;
};
export type OrgAudienceQuery = {
page: number;
limit: number;
search?: string;
sortBy: TAudienceSortBy;
sortOrder: TAudienceSortOrder;
};
+89
View File
@@ -0,0 +1,89 @@
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import {
getCourseProgress as getCourseProgressQuery,
type TCourseCertificationRow
} from '@cio/db/queries/course/course';
import { setMemberCertificateEarned, setMemberCertificationEmailSent } from '@cio/db/queries/course/people';
import { getProfileById } from '@cio/db/queries/auth';
import { buildEmailFromName, sendEmail } from '@cio/email';
export function calcCourseProgressPercent(params: {
lessonsCompleted: number;
totalLessons: number;
exercisesCompleted: number;
exercisesCount: number;
}): number {
const totalItems = params.totalLessons + params.exercisesCount;
if (totalItems === 0) return 0;
const completedItems = params.lessonsCompleted + params.exercisesCompleted;
return Math.round((completedItems / totalItems) * 100);
}
export function isBeforeOrEqualDeadline(deadlineIso: string | null | undefined, now: Date): boolean {
if (!deadlineIso) return true;
const deadlineDate = new Date(deadlineIso);
if (Number.isNaN(deadlineDate.getTime())) return true;
return now.getTime() <= deadlineDate.getTime();
}
/**
* Persists certificate earned time, then sends the completion email and records
* {@link setMemberCertificationEmailSent}, without blocking the HTTP handler.
* Re-fetches progress so concurrent jobs can skip work already done.
*/
export function scheduleCertificationCompletionWork(params: {
courseId: string;
profileId: string;
groupMemberId: string;
courseRow: TCourseCertificationRow;
earnedAt: string;
}): void {
const { courseId, profileId, groupMemberId, courseRow, earnedAt } = params;
void (async () => {
try {
let latest = await getCourseProgressQuery(courseId, profileId);
if (!latest.certificateEarnedAt) {
await setMemberCertificateEarned(groupMemberId, earnedAt);
latest = await getCourseProgressQuery(courseId, profileId);
}
if (latest.certificationEmailSentAt) {
return;
}
const profile = await getProfileById(profileId);
const studentEmail = profile?.email;
if (!studentEmail) {
return;
}
const base = getDashboardBaseUrl(courseRow.orgSiteName ?? undefined);
const certificateUrl = `${base}/courses/${courseId}/certificates`;
await sendEmail('studentCourseCompletion', {
to: studentEmail,
fields: {
orgName: courseRow.orgName,
courseName: courseRow.title,
studentName: profile?.fullname || studentEmail,
certificateUrl,
customMessage: courseRow.certificate?.emailMessage ?? null
},
from: buildEmailFromName(`${courseRow.orgName} (via ClassroomIO.com)`)
});
await setMemberCertificationEmailSent(groupMemberId, new Date().toISOString());
} catch (error) {
console.error('scheduleCertificationCompletionWork: failed:', error);
}
})();
}
+1
View File
@@ -60,6 +60,7 @@ export const ErrorCodes = {
ORG_PLAN_NOT_FOUND: 'ORG_PLAN_NOT_FOUND',
ORG_TEAM_INVITE_FAILED: 'ORG_TEAM_INVITE_FAILED',
ORG_TEAM_REMOVE_FAILED: 'ORG_TEAM_REMOVE_FAILED',
ORG_AUDIENCE_REMOVE_FAILED: 'ORG_AUDIENCE_REMOVE_FAILED',
ORG_TEAM_EMAIL_EXISTS: 'ORG_TEAM_EMAIL_EXISTS',
ORG_TEAM_NOT_AUTHORIZED: 'ORG_TEAM_NOT_AUTHORIZED',
ORGANIZATION_API_KEY_NOT_FOUND: 'ORGANIZATION_API_KEY_NOT_FOUND',
+20 -7
View File
@@ -6,25 +6,38 @@ const client: RedisClientType = createClient({
url: env.REDIS_URL
});
let hasLoggedRedisUnavailable = false;
export function logRedisUnavailableOnce(message: string, error?: unknown): void {
if (hasLoggedRedisUnavailable) {
return;
}
hasLoggedRedisUnavailable = true;
if (error) {
console.warn(message, error);
return;
}
console.warn(message);
}
// Handle connection events
client.on('error', (err) => {
console.error('Redis Client Error:', err);
logRedisUnavailableOnce('Redis unavailable, Redis-backed features disabled', err);
});
client.on('connect', () => {
console.log('Redis Client Connected');
});
client.on('reconnecting', () => {
console.log('Redis Client Reconnecting');
});
// Track connection state
let isConnected = false;
export async function connectRedis(): Promise<void> {
if (!env.REDIS_URL) {
console.warn('REDIS_URL not set, Redis features disabled');
logRedisUnavailableOnce('REDIS_URL not set, Redis features disabled');
return;
}
if (isConnected) return;
@@ -32,7 +45,7 @@ export async function connectRedis(): Promise<void> {
await client.connect();
isConnected = true;
} catch (error) {
console.error('Redis connection failed, API will run without Redis:', error);
logRedisUnavailableOnce('Redis connection failed, API will run without Redis', error);
// Don't throw - allow API to start; rate limiting and caching will degrade gracefully
}
}
+3 -3
View File
@@ -9,7 +9,7 @@ import {
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { getPresignS3Client, getS3Client, getStorageConfig } from '@api/config/storage';
import { redis } from '@api/utils/redis/redis';
import { logRedisUnavailableOnce, redis } from '@api/utils/redis/redis';
export type GetSignedUrlParameters = Parameters<typeof getSignedUrl>;
@@ -70,7 +70,7 @@ export async function generateDownloadPresignedUrls(
try {
cachedValues = await redis.mGet(cacheKeys);
} catch (error) {
console.error('Redis mGet failed for presigned URL cache, falling back to S3:', error);
logRedisUnavailableOnce('Redis cache unavailable for presigned URLs, falling back to S3', error);
}
const keysToGenerate: string[] = [];
@@ -121,7 +121,7 @@ export async function generateDownloadPresignedUrls(
});
await pipeline.exec();
} catch (error) {
console.error('Redis setEx failed for presigned URL cache:', error);
logRedisUnavailableOnce('Redis cache write unavailable for presigned URLs, continuing without cache', error);
// URLs are already in signedUrls; cache write failure is non-fatal
}
+39
View File
@@ -0,0 +1,39 @@
import { createSanitizeHtmlConfig, stripSvgDataUrls } from '@cio/utils/functions';
import DOMPurify from 'isomorphic-dompurify';
export function sanitizeHtml(html: string): string {
let sanitized = DOMPurify.sanitize(html, createSanitizeHtmlConfig());
sanitized = stripSvgDataUrls(sanitized);
return sanitized;
}
export function sanitizeOptionalHtml(html: string | undefined): string | undefined;
export function sanitizeOptionalHtml(html: string | null | undefined): string | null | undefined;
export function sanitizeOptionalHtml(html: string | null | undefined): string | null | undefined {
if (html == null) return html;
return sanitizeHtml(html);
}
export function sanitizeUnknownStrings<T>(value: T): T {
if (typeof value === 'string') {
return sanitizeHtml(value) as T;
}
if (Array.isArray(value)) {
return value.map((item) => sanitizeUnknownStrings(item)) as T;
}
if (value && typeof value === 'object') {
const sanitizedEntries = Object.entries(value).map(([key, entryValue]) => [
key,
sanitizeUnknownStrings(entryValue)
]);
return Object.fromEntries(sanitizedEntries) as T;
}
return value;
}