feat: certification rules, content sanitization, handle draft course updates and reset content, better redis logging
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -182,8 +182,7 @@ export async function cloneCourse(
|
||||
currency: course.currency,
|
||||
bannerImage: course.bannerImage,
|
||||
isPublished: course.isPublished,
|
||||
isCertificateDownloadable: course.isCertificateDownloadable,
|
||||
certificateTheme: course.certificateTheme,
|
||||
certificate: course.certificate,
|
||||
status: course.status,
|
||||
type: course.type
|
||||
});
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import * as schema from '@cio/db/schema';
|
||||
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import {
|
||||
calcCourseProgressPercent,
|
||||
isBeforeOrEqualDeadline,
|
||||
scheduleCertificationCompletionWork
|
||||
} from '@api/utils/course-completion';
|
||||
import {
|
||||
exerciseBelongsToCourse,
|
||||
getExerciseTitleAndMaxPoints,
|
||||
getStudentSubmissionsForExercise
|
||||
} from '@cio/db/queries/course/certification-exercise';
|
||||
import {
|
||||
getCourseCertificationRow,
|
||||
getCourseProgress as getCourseProgressQuery,
|
||||
type TCourseCertificationRow
|
||||
} from '@cio/db/queries/course/course';
|
||||
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export type CertificationBlocker = {
|
||||
code:
|
||||
| 'CERT_PROGRESS'
|
||||
| 'CERT_DEADLINE_PASSED'
|
||||
| 'CERT_NO_CONTENT'
|
||||
| 'CERT_FINAL_EXERCISE_NOT_SUBMITTED'
|
||||
| 'CERT_FINAL_EXERCISE_PENDING_GRADE'
|
||||
| 'CERT_FINAL_EXERCISE_SCORE'
|
||||
| 'CERT_FINAL_EXERCISE_MISCONFIGURED';
|
||||
params?: Record<string, string | number>;
|
||||
};
|
||||
|
||||
export type CourseCompletionEvaluation = {
|
||||
progressPercent: number;
|
||||
certificationThreshold: number;
|
||||
meetsThreshold: boolean;
|
||||
eligibleForCertificate: boolean;
|
||||
certificateEarnedAt: string | null;
|
||||
blockers: CertificationBlocker[];
|
||||
meetsFinalExerciseRule: boolean;
|
||||
};
|
||||
|
||||
function percentFromTotal(total: number, maxPoints: number): number {
|
||||
if (maxPoints <= 0) return 0;
|
||||
return Math.round((total / maxPoints) * 100);
|
||||
}
|
||||
|
||||
async function evaluateFinalExerciseRule(params: {
|
||||
courseId: string;
|
||||
requiredExerciseId: string | null | undefined;
|
||||
minScorePercent: number | null | undefined;
|
||||
groupMemberId: string | null;
|
||||
}): Promise<{
|
||||
meetsFinalExerciseRule: boolean;
|
||||
blockers: CertificationBlocker[];
|
||||
}> {
|
||||
const { courseId, requiredExerciseId, minScorePercent, groupMemberId } = params;
|
||||
|
||||
if (!requiredExerciseId) {
|
||||
return { meetsFinalExerciseRule: true, blockers: [] };
|
||||
}
|
||||
|
||||
const belongs = await exerciseBelongsToCourse(requiredExerciseId, courseId);
|
||||
if (!belongs) {
|
||||
return { meetsFinalExerciseRule: true, blockers: [] };
|
||||
}
|
||||
|
||||
const requiredPercent = minScorePercent ?? 100;
|
||||
|
||||
if (!groupMemberId) {
|
||||
return {
|
||||
meetsFinalExerciseRule: false,
|
||||
blockers: [{ code: 'CERT_FINAL_EXERCISE_NOT_SUBMITTED' }]
|
||||
};
|
||||
}
|
||||
|
||||
const { title: exerciseTitle, maxPoints } = await getExerciseTitleAndMaxPoints(requiredExerciseId);
|
||||
|
||||
if (maxPoints <= 0) {
|
||||
return {
|
||||
meetsFinalExerciseRule: false,
|
||||
blockers: [
|
||||
{
|
||||
code: 'CERT_FINAL_EXERCISE_MISCONFIGURED',
|
||||
params: { exerciseTitle }
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
const submissions = await getStudentSubmissionsForExercise(groupMemberId, requiredExerciseId);
|
||||
|
||||
if (submissions.length === 0) {
|
||||
return {
|
||||
meetsFinalExerciseRule: false,
|
||||
blockers: [
|
||||
{
|
||||
code: 'CERT_FINAL_EXERCISE_NOT_SUBMITTED',
|
||||
params: { exerciseTitle }
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
const completed = submissions.filter((s) => s.gradingState === 'completed');
|
||||
if (completed.length === 0) {
|
||||
return {
|
||||
meetsFinalExerciseRule: false,
|
||||
blockers: [
|
||||
{
|
||||
code: 'CERT_FINAL_EXERCISE_PENDING_GRADE',
|
||||
params: { exerciseTitle }
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
let bestPercent = 0;
|
||||
for (const s of completed) {
|
||||
const total = Number(s.total ?? 0);
|
||||
const p = percentFromTotal(total, maxPoints);
|
||||
if (p > bestPercent) bestPercent = p;
|
||||
}
|
||||
|
||||
if (bestPercent < requiredPercent) {
|
||||
return {
|
||||
meetsFinalExerciseRule: false,
|
||||
blockers: [
|
||||
{
|
||||
code: 'CERT_FINAL_EXERCISE_SCORE',
|
||||
params: {
|
||||
exerciseTitle,
|
||||
bestPercent,
|
||||
requiredPercent
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
return { meetsFinalExerciseRule: true, blockers: [] };
|
||||
}
|
||||
|
||||
async function buildCertificationEvaluation(
|
||||
courseId: string,
|
||||
profileId: string,
|
||||
courseRow: TCourseCertificationRow,
|
||||
progressArg?: Awaited<ReturnType<typeof getCourseProgressQuery>>
|
||||
): Promise<CourseCompletionEvaluation> {
|
||||
const progress = progressArg ?? (await getCourseProgressQuery(courseId, profileId));
|
||||
const certificate = courseRow.certificate ?? {};
|
||||
|
||||
const threshold = certificate.threshold ?? 100;
|
||||
const progressPercent = calcCourseProgressPercent({
|
||||
lessonsCompleted: progress.lessonsCompleted,
|
||||
totalLessons: progress.lessonsCount,
|
||||
exercisesCompleted: progress.exercisesCompleted,
|
||||
exercisesCount: progress.exercisesCount
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const meetsThreshold = progressPercent >= threshold;
|
||||
const withinDeadline = isBeforeOrEqualDeadline(certificate.deadline, now);
|
||||
const hasContent = progress.lessonsCount + progress.exercisesCount > 0;
|
||||
|
||||
const blockers: CertificationBlocker[] = [];
|
||||
|
||||
if (!withinDeadline) {
|
||||
blockers.push({ code: 'CERT_DEADLINE_PASSED' });
|
||||
}
|
||||
if (!meetsThreshold) {
|
||||
blockers.push({
|
||||
code: 'CERT_PROGRESS',
|
||||
params: { current: progressPercent, required: threshold }
|
||||
});
|
||||
}
|
||||
if (!hasContent) {
|
||||
blockers.push({ code: 'CERT_NO_CONTENT' });
|
||||
}
|
||||
|
||||
const finalEval = await evaluateFinalExerciseRule({
|
||||
courseId,
|
||||
requiredExerciseId: certificate.requiredExerciseId,
|
||||
minScorePercent: certificate.exerciseMinScorePercent,
|
||||
groupMemberId: progress.groupMemberId
|
||||
});
|
||||
|
||||
blockers.push(...finalEval.blockers);
|
||||
|
||||
const eligibleForCertificate =
|
||||
progress.roleId === ROLE.STUDENT &&
|
||||
!!progress.groupMemberId &&
|
||||
meetsThreshold &&
|
||||
hasContent &&
|
||||
withinDeadline &&
|
||||
finalEval.meetsFinalExerciseRule;
|
||||
|
||||
return {
|
||||
progressPercent,
|
||||
certificationThreshold: threshold,
|
||||
meetsThreshold,
|
||||
eligibleForCertificate,
|
||||
certificateEarnedAt: progress.certificateEarnedAt,
|
||||
blockers,
|
||||
meetsFinalExerciseRule: finalEval.meetsFinalExerciseRule
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates certification eligibility. On first eligibility, persists
|
||||
* `certificateEarnedAt` and fires the completion email (non-blocking).
|
||||
*/
|
||||
export async function evaluateCourseCertification(
|
||||
courseId: string,
|
||||
profileId: string
|
||||
): Promise<CourseCompletionEvaluation & { isNewCompletion: boolean }> {
|
||||
const [progress, courseRow] = await Promise.all([
|
||||
getCourseProgressQuery(courseId, profileId),
|
||||
getCourseCertificationRow(courseId)
|
||||
]);
|
||||
|
||||
if (!courseRow) {
|
||||
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const evaluation = await buildCertificationEvaluation(courseId, profileId, courseRow, progress);
|
||||
|
||||
let certificateEarnedAt = evaluation.certificateEarnedAt;
|
||||
let isNewCompletion = false;
|
||||
|
||||
if (evaluation.eligibleForCertificate && !certificateEarnedAt && progress.groupMemberId) {
|
||||
const earnedAt = new Date().toISOString();
|
||||
certificateEarnedAt = earnedAt;
|
||||
isNewCompletion = true;
|
||||
|
||||
scheduleCertificationCompletionWork({
|
||||
courseId,
|
||||
profileId,
|
||||
groupMemberId: progress.groupMemberId,
|
||||
courseRow,
|
||||
earnedAt
|
||||
});
|
||||
}
|
||||
|
||||
return { ...evaluation, certificateEarnedAt, isNewCompletion };
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side guard for PDF certificate download (must match dashboard eligibility).
|
||||
*/
|
||||
export async function assertCertificateDownloadAllowed(courseId: string, profileId: string): Promise<void> {
|
||||
const evaluation = await evaluateCourseCertification(courseId, profileId);
|
||||
|
||||
const [courseMeta] = await db
|
||||
.select({
|
||||
certificate: schema.course.certificate
|
||||
})
|
||||
.from(schema.course)
|
||||
.where(eq(schema.course.id, courseId))
|
||||
.limit(1);
|
||||
|
||||
if (!courseMeta?.certificate?.isDownloadable) {
|
||||
throw new AppError('Certificate download is not enabled for this course', ErrorCodes.VALIDATION_ERROR, 403);
|
||||
}
|
||||
|
||||
const hasEarnedPreviously = evaluation.certificateEarnedAt != null;
|
||||
const canDownload = hasEarnedPreviously || evaluation.eligibleForCertificate;
|
||||
|
||||
if (!canDownload) {
|
||||
throw new AppError('Course completion requirements not met', ErrorCodes.VALIDATION_ERROR, 403);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { sanitizeHtml, sanitizeOptionalHtml, sanitizeUnknownStrings } from '@api/utils/sanitize-html';
|
||||
import { addGroupMember, createGroup } from '@cio/db/queries/group';
|
||||
import {
|
||||
createCourseNewsfeed,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
import { ContentType, ROLE } from '@cio/utils/constants';
|
||||
import type { TCourse } from '@cio/db/types';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import { exerciseBelongsToCourse } from '@cio/db/queries/course/certification-exercise';
|
||||
import { updateExercisesSectionId } from '@cio/db/queries/exercise/exercise';
|
||||
import { getProfileById } from '@cio/db/queries/auth';
|
||||
import { buildCourseContent, calcPercentageWithRounding, formatLastSeen, type CourseContent } from './utils';
|
||||
@@ -27,6 +29,42 @@ import { buildCourseContent, calcPercentageWithRounding, formatLastSeen, type Co
|
||||
const DEFAULT_CONTENT_GROUPING = true;
|
||||
const DEFAULT_SECTION_TITLE = 'First Section [edit me]';
|
||||
|
||||
function sanitizeCourseMetadata(metadata: TCourse['metadata'] | undefined) {
|
||||
if (!metadata) return metadata;
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
description: sanitizeOptionalHtml(metadata.description),
|
||||
goals: sanitizeOptionalHtml(metadata.goals),
|
||||
requirements: sanitizeOptionalHtml(metadata.requirements),
|
||||
reward: metadata.reward
|
||||
? {
|
||||
...metadata.reward,
|
||||
description: sanitizeHtml(metadata.reward.description)
|
||||
}
|
||||
: metadata.reward,
|
||||
instructor: metadata.instructor
|
||||
? {
|
||||
...metadata.instructor,
|
||||
description: sanitizeHtml(metadata.instructor.description)
|
||||
}
|
||||
: metadata.instructor,
|
||||
reviews: metadata.reviews?.map((review) => ({
|
||||
...review,
|
||||
description: sanitizeHtml(review.description)
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeCourseCertificate(certificate: TCourse['certificate'] | undefined) {
|
||||
if (!certificate) return certificate;
|
||||
|
||||
return {
|
||||
...certificate,
|
||||
emailMessage: sanitizeOptionalHtml(certificate.emailMessage)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a course by ID or slug with all related data
|
||||
* @param courseId Course ID (optional if slug provided)
|
||||
@@ -93,11 +131,13 @@ export async function createCourse(
|
||||
data: { title: string; description: string; type: 'LIVE_CLASS' | 'SELF_PACED'; organizationId: string }
|
||||
): Promise<{ course: TCourse; groupId: string; memberId: string }> {
|
||||
try {
|
||||
const description = sanitizeHtml(data.description);
|
||||
|
||||
const result = await db.transaction(async () => {
|
||||
// 1. Create group
|
||||
const [newGroup] = await createGroup({
|
||||
name: data.title,
|
||||
description: data.description,
|
||||
description,
|
||||
organizationId: data.organizationId
|
||||
});
|
||||
|
||||
@@ -108,7 +148,7 @@ export async function createCourse(
|
||||
// 2. Create course with group_id
|
||||
const [newCourse] = await createCourseQuery({
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
description,
|
||||
type: data.type,
|
||||
groupId: newGroup.id
|
||||
});
|
||||
@@ -130,8 +170,8 @@ export async function createCourse(
|
||||
|
||||
// 4. Add default news feed
|
||||
await createCourseNewsfeed({
|
||||
content: `<h2>Welcome to this course 🎉 </h2>
|
||||
<p>Thank you for joining this course and I hope you get the best out of it.</p>`,
|
||||
content: sanitizeHtml(`<h2>Welcome to this course 🎉 </h2>
|
||||
<p>Thank you for joining this course and I hope you get the best out of it.</p>`),
|
||||
courseId: newCourse.id,
|
||||
isPinned: true,
|
||||
authorId: newMember.id
|
||||
@@ -166,7 +206,24 @@ export async function createCourse(
|
||||
*/
|
||||
export async function updateCourse(courseId: string, data: Partial<TCourse>) {
|
||||
try {
|
||||
const updated = await updateCourseQuery(courseId, data);
|
||||
const sanitizedData: Partial<TCourse> = {
|
||||
...data,
|
||||
description: sanitizeOptionalHtml(data.description),
|
||||
overview: sanitizeOptionalHtml(data.overview),
|
||||
metadata: sanitizeCourseMetadata(data.metadata),
|
||||
certificate: sanitizeCourseCertificate(data.certificate),
|
||||
logo: data.logo,
|
||||
slug: data.slug
|
||||
};
|
||||
|
||||
if (data.certificate?.requiredExerciseId) {
|
||||
const ok = await exerciseBelongsToCourse(data.certificate.requiredExerciseId, courseId);
|
||||
if (!ok) {
|
||||
throw new AppError('Certification exercise must belong to this course', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await updateCourseQuery(courseId, sanitizeUnknownStrings(sanitizedData));
|
||||
if (!updated) {
|
||||
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
@@ -343,15 +343,18 @@ async function sendStudentJoinEmails(input: {
|
||||
courseId: string;
|
||||
courseName: string;
|
||||
orgName: string;
|
||||
orgSiteName: string | null;
|
||||
studentId: string;
|
||||
studentEmail: string;
|
||||
}) {
|
||||
try {
|
||||
const loginUrl = getDashboardBaseUrl(input.orgSiteName ?? undefined);
|
||||
await sendEmail('studentCourseWelcome', {
|
||||
to: input.studentEmail,
|
||||
fields: {
|
||||
orgName: input.orgName,
|
||||
courseName: input.courseName
|
||||
courseName: input.courseName,
|
||||
loginUrl
|
||||
},
|
||||
from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`)
|
||||
});
|
||||
@@ -678,6 +681,7 @@ export async function enrollInCourse(
|
||||
courseId,
|
||||
courseName: title,
|
||||
orgName: org.name,
|
||||
orgSiteName: org.siteName,
|
||||
studentId: user.id,
|
||||
studentEmail: normalizedEmail
|
||||
});
|
||||
@@ -962,7 +966,8 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
|
||||
alreadyJoined: true,
|
||||
courseId: course.id,
|
||||
courseName: course.title,
|
||||
orgName: organization.name
|
||||
orgName: organization.name,
|
||||
orgSiteName: organization.siteName
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1061,7 +1066,8 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
|
||||
alreadyJoined: false,
|
||||
courseId: course.id,
|
||||
courseName: course.title,
|
||||
orgName: organization.name
|
||||
orgName: organization.name,
|
||||
orgSiteName: organization.siteName
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1070,6 +1076,7 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
|
||||
courseId: result.courseId,
|
||||
courseName: result.courseName,
|
||||
orgName: result.orgName,
|
||||
orgSiteName: result.orgSiteName,
|
||||
studentId: user.id,
|
||||
studentEmail: normalizedEmail
|
||||
});
|
||||
|
||||
@@ -62,6 +62,7 @@ export async function addMember(
|
||||
if (courseOrgData) {
|
||||
const courseName = courseOrgData.courseTitle || '';
|
||||
const orgName = courseOrgData.orgName || 'ClassroomIO';
|
||||
const loginUrl = getDashboardBaseUrl(courseOrgData.orgSiteName ?? undefined);
|
||||
|
||||
// Get student profile data
|
||||
let studentEmail: string | null = null;
|
||||
@@ -86,7 +87,8 @@ export async function addMember(
|
||||
to: studentEmail,
|
||||
fields: {
|
||||
orgName,
|
||||
courseName
|
||||
courseName,
|
||||
loginUrl
|
||||
},
|
||||
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`)
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user