feat: widgets and ai chat
This commit is contained in:
@@ -152,6 +152,7 @@ async function buildCertificationEvaluation(
|
||||
): Promise<CourseCompletionEvaluation> {
|
||||
const progress = progressArg ?? (await getCourseProgressQuery(courseId, profileId));
|
||||
const certificate = courseRow.certificate ?? {};
|
||||
const complianceMinScore = courseRow.type === 'COMPLIANCE' ? (courseRow.compliance?.passingScore ?? null) : null;
|
||||
|
||||
const threshold = certificate.threshold ?? 100;
|
||||
const progressPercent = calcCourseProgressPercent({
|
||||
@@ -184,7 +185,7 @@ async function buildCertificationEvaluation(
|
||||
const finalEval = await evaluateFinalExerciseRule({
|
||||
courseId,
|
||||
requiredExerciseId: certificate.requiredExerciseId,
|
||||
minScorePercent: certificate.exerciseMinScorePercent,
|
||||
minScorePercent: certificate.exerciseMinScorePercent ?? complianceMinScore,
|
||||
groupMemberId: progress.groupMemberId
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,714 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import {
|
||||
createCourseCertificateIssue,
|
||||
createCourseCompletionNotificationEvent,
|
||||
createCourseCompletionRecord,
|
||||
getCourseComplianceHistoryRows,
|
||||
getCourseCurrentComplianceRows,
|
||||
getLatestComplianceRecordsByProfiles,
|
||||
getStudentCourseMembersForCompliance,
|
||||
listComplianceRecordsReadyForExpiry,
|
||||
listLatestComplianceRecordsForReminderScan,
|
||||
updateCourseCertificateIssueStatusByRecordId,
|
||||
updateCourseCompletionRecord
|
||||
} from '@cio/db/queries/course/compliance';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import { getCourseById } from '@cio/db/queries/course';
|
||||
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import type {
|
||||
TCourseComplianceExtend,
|
||||
TCourseComplianceReset,
|
||||
TCourseComplianceWaive
|
||||
} from '@cio/utils/validation/course/compliance';
|
||||
import type { TCourseCompletionRecord } from '@cio/db/types';
|
||||
import {
|
||||
getExerciseTitleAndMaxPoints,
|
||||
getStudentSubmissionsForExercise
|
||||
} from '@cio/db/queries/course/certification-exercise';
|
||||
import { getProfileByGroupMemberId } from '@cio/db/queries/course/people';
|
||||
import { getUserCourseRole } from '@cio/db/queries/group/group';
|
||||
import { evaluateCourseCertification } from './completion';
|
||||
|
||||
type ComplianceStatus =
|
||||
| 'not_started'
|
||||
| 'in_progress'
|
||||
| 'compliant'
|
||||
| 'expiring_soon'
|
||||
| 'in_grace_period'
|
||||
| 'non_compliant'
|
||||
| 'waived';
|
||||
|
||||
type ComplianceSettings = NonNullable<Awaited<ReturnType<typeof getComplianceCourseOrThrow>>['compliance']>;
|
||||
|
||||
function ensureFutureOrPresentIsoDate(dateIso: string, field: string) {
|
||||
const date = new Date(dateIso);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new AppError('Invalid date provided', ErrorCodes.VALIDATION_ERROR, 400, field);
|
||||
}
|
||||
}
|
||||
|
||||
function getReminderEventType(daysUntilDue: number) {
|
||||
return `reminder_${daysUntilDue}d`;
|
||||
}
|
||||
|
||||
function addMonths(isoDate: string, months: number) {
|
||||
const date = new Date(isoDate);
|
||||
date.setUTCMonth(date.getUTCMonth() + months);
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function getWholeDaysUntil(startDate: Date, endDate: Date) {
|
||||
const millisecondsPerDay = 24 * 60 * 60 * 1000;
|
||||
return Math.ceil((endDate.getTime() - startDate.getTime()) / millisecondsPerDay);
|
||||
}
|
||||
|
||||
function getStatusCounts(records: Array<{ record: TCourseCompletionRecord | null }>) {
|
||||
const counts: Record<ComplianceStatus | 'no_record', number> = {
|
||||
no_record: 0,
|
||||
not_started: 0,
|
||||
in_progress: 0,
|
||||
compliant: 0,
|
||||
expiring_soon: 0,
|
||||
in_grace_period: 0,
|
||||
non_compliant: 0,
|
||||
waived: 0
|
||||
};
|
||||
|
||||
for (const row of records) {
|
||||
if (!row.record) {
|
||||
counts.no_record += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const status = row.record.status as ComplianceStatus;
|
||||
counts[status] = (counts[status] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
async function getComplianceCourseOrThrow(courseId: string) {
|
||||
const [course] = await getCourseById(courseId);
|
||||
|
||||
if (!course) {
|
||||
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
if (course.type !== 'COMPLIANCE' || !course.compliance) {
|
||||
throw new AppError('Course is not a compliance course', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
return course;
|
||||
}
|
||||
|
||||
async function assertCourseComplianceHistoryAccess(
|
||||
courseId: string,
|
||||
currentProfileId: string,
|
||||
targetProfileId: string
|
||||
) {
|
||||
if (currentProfileId === targetProfileId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const roleId = await getUserCourseRole(courseId, currentProfileId);
|
||||
|
||||
if (roleId !== ROLE.ADMIN && roleId !== ROLE.TUTOR) {
|
||||
throw new AppError('You do not have permission to view this learner', ErrorCodes.VALIDATION_ERROR, 403);
|
||||
}
|
||||
}
|
||||
|
||||
async function getTargetStudentMembers(courseId: string, profileIds?: string[]) {
|
||||
const students = await getStudentCourseMembersForCompliance(courseId, profileIds);
|
||||
|
||||
if (profileIds && students.length !== profileIds.length) {
|
||||
throw new AppError(
|
||||
'One or more learners were not found in this course',
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
400,
|
||||
'profileIds'
|
||||
);
|
||||
}
|
||||
|
||||
return students;
|
||||
}
|
||||
|
||||
function resolveComplianceInitialDueDate(course: Awaited<ReturnType<typeof getComplianceCourseOrThrow>>) {
|
||||
const dueDate = course.certificate?.deadline ?? null;
|
||||
|
||||
if (!dueDate) {
|
||||
throw new AppError(
|
||||
'Compliance courses require a certificate deadline before learners can be enrolled',
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
400,
|
||||
'certificate.deadline'
|
||||
);
|
||||
}
|
||||
|
||||
const parsedDueDate = new Date(dueDate);
|
||||
if (Number.isNaN(parsedDueDate.getTime())) {
|
||||
throw new AppError(
|
||||
'Compliance course deadline is invalid',
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
400,
|
||||
'certificate.deadline'
|
||||
);
|
||||
}
|
||||
|
||||
return dueDate;
|
||||
}
|
||||
|
||||
function getCurrentIncompleteRecord(record: TCourseCompletionRecord | undefined | null) {
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (record.completedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
async function getComplianceCompletionSnapshot(params: {
|
||||
groupMemberId: string;
|
||||
requiredExerciseId: string | null | undefined;
|
||||
}) {
|
||||
const { groupMemberId, requiredExerciseId } = params;
|
||||
|
||||
if (!requiredExerciseId) {
|
||||
return {
|
||||
score: null,
|
||||
attempts: null
|
||||
};
|
||||
}
|
||||
|
||||
const { maxPoints } = await getExerciseTitleAndMaxPoints(requiredExerciseId);
|
||||
const submissions = await getStudentSubmissionsForExercise(groupMemberId, requiredExerciseId);
|
||||
const completedSubmissions = submissions.filter((submission) => submission.gradingState === 'completed');
|
||||
|
||||
if (completedSubmissions.length === 0 || maxPoints <= 0) {
|
||||
return {
|
||||
score: null,
|
||||
attempts: submissions.length
|
||||
};
|
||||
}
|
||||
|
||||
const bestScorePercent = completedSubmissions.reduce((highestPercent, submission) => {
|
||||
const totalPoints = Number(submission.total ?? 0);
|
||||
const nextPercent = Math.round((totalPoints / maxPoints) * 100);
|
||||
|
||||
return Math.max(highestPercent, nextPercent);
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
score: bestScorePercent,
|
||||
attempts: submissions.length
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureComplianceEnrollmentRecordForLearner(courseId: string, groupMemberId: string, profileId: string) {
|
||||
const course = await getComplianceCourseOrThrow(courseId);
|
||||
const dueDate = resolveComplianceInitialDueDate(course);
|
||||
const [existingRecord] = await getLatestComplianceRecordsByProfiles(courseId, [profileId]);
|
||||
|
||||
if (existingRecord) {
|
||||
return existingRecord;
|
||||
}
|
||||
|
||||
return createCourseCompletionRecord({
|
||||
courseId,
|
||||
groupMemberId,
|
||||
profileId,
|
||||
cycleNumber: 1,
|
||||
status: 'not_started',
|
||||
dueDate,
|
||||
attempts: 0,
|
||||
timeSpentMinutes: 0
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureComplianceEnrollmentRecordsForProfiles(courseIds: string[], profileIds: string[]) {
|
||||
if (courseIds.length === 0 || profileIds.length === 0) {
|
||||
return {
|
||||
createdCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
let createdCount = 0;
|
||||
|
||||
for (const courseId of courseIds) {
|
||||
const [course] = await getCourseById(courseId);
|
||||
|
||||
if (!course || course.type !== 'COMPLIANCE') {
|
||||
continue;
|
||||
}
|
||||
|
||||
resolveComplianceInitialDueDate(await getComplianceCourseOrThrow(courseId));
|
||||
|
||||
const learners = await getStudentCourseMembersForCompliance(courseId, profileIds);
|
||||
for (const learner of learners) {
|
||||
const profileId = learner.member.profileId;
|
||||
if (!profileId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [existingRecord] = await getLatestComplianceRecordsByProfiles(courseId, [profileId]);
|
||||
if (existingRecord) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await ensureComplianceEnrollmentRecordForLearner(courseId, learner.member.id, profileId);
|
||||
createdCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
createdCount
|
||||
};
|
||||
}
|
||||
|
||||
export async function syncComplianceProgressFromSubmission(courseId: string, groupMemberId: string) {
|
||||
const [course] = await getCourseById(courseId);
|
||||
|
||||
if (!course || course.type !== 'COMPLIANCE') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const profile = await getProfileByGroupMemberId(groupMemberId);
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeRecord = await ensureComplianceEnrollmentRecordForLearner(courseId, groupMemberId, profile.id);
|
||||
if (!activeRecord.completedAt && activeRecord.status === 'not_started') {
|
||||
await updateCourseCompletionRecord(activeRecord.id, {
|
||||
status: 'in_progress',
|
||||
startedAt: activeRecord.startedAt ?? new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
const evaluation = await evaluateCourseCertification(courseId, profile.id);
|
||||
if (!evaluation.eligibleForCertificate) {
|
||||
return {
|
||||
status: 'in_progress',
|
||||
completed: false
|
||||
};
|
||||
}
|
||||
|
||||
const latestRecord = await ensureComplianceEnrollmentRecordForLearner(courseId, groupMemberId, profile.id);
|
||||
if (latestRecord.completedAt) {
|
||||
return {
|
||||
status: latestRecord.status,
|
||||
completed: true
|
||||
};
|
||||
}
|
||||
|
||||
const completedAt = new Date().toISOString();
|
||||
const validUntil = addMonths(completedAt, course.compliance?.retakeIntervalMonths ?? 0);
|
||||
const completionSnapshot = await getComplianceCompletionSnapshot({
|
||||
groupMemberId,
|
||||
requiredExerciseId: course.certificate?.requiredExerciseId ?? null
|
||||
});
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await updateCourseCompletionRecord(
|
||||
latestRecord.id,
|
||||
{
|
||||
status: 'compliant',
|
||||
startedAt: latestRecord.startedAt ?? completedAt,
|
||||
completedAt,
|
||||
validUntil,
|
||||
score: completionSnapshot.score ?? latestRecord.score,
|
||||
attempts: completionSnapshot.attempts ?? latestRecord.attempts
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
if (course.certificate?.isDownloadable) {
|
||||
await createCourseCertificateIssue(
|
||||
{
|
||||
courseId,
|
||||
profileId: profile.id,
|
||||
courseCompletionRecordId: latestRecord.id,
|
||||
cycleNumber: latestRecord.cycleNumber,
|
||||
expiresAt: validUntil,
|
||||
status: 'valid'
|
||||
},
|
||||
tx
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
status: 'compliant',
|
||||
completed: true,
|
||||
validUntil
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCourseComplianceOverview(courseId: string) {
|
||||
const course = await getComplianceCourseOrThrow(courseId);
|
||||
const rows = await getCourseCurrentComplianceRows(courseId);
|
||||
const counts = getStatusCounts(rows);
|
||||
|
||||
return {
|
||||
courseId: course.id,
|
||||
compliance: course.compliance,
|
||||
summary: {
|
||||
totalLearners: rows.length,
|
||||
noRecord: counts.no_record,
|
||||
notStarted: counts.not_started,
|
||||
inProgress: counts.in_progress,
|
||||
compliant: counts.compliant,
|
||||
expiringSoon: counts.expiring_soon,
|
||||
inGracePeriod: counts.in_grace_period,
|
||||
nonCompliant: counts.non_compliant,
|
||||
waived: counts.waived
|
||||
},
|
||||
learners: rows.map((row) => ({
|
||||
groupMemberId: row.member.id,
|
||||
profileId: row.member.profileId,
|
||||
fullname: row.profile?.fullname ?? null,
|
||||
username: row.profile?.username ?? null,
|
||||
email: row.profile?.email ?? row.member.email ?? null,
|
||||
status: row.record?.status ?? 'not_started',
|
||||
cycleNumber: row.record?.cycleNumber ?? null,
|
||||
dueDate: row.record?.dueDate ?? null,
|
||||
completedAt: row.record?.completedAt ?? null,
|
||||
validUntil: row.record?.validUntil ?? null,
|
||||
score: row.record?.score ?? null,
|
||||
attempts: row.record?.attempts ?? 0,
|
||||
waiverExpiresAt: row.record?.waiverExpiresAt ?? null
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLearnerComplianceHistory(courseId: string, currentProfileId: string, targetProfileId: string) {
|
||||
await getComplianceCourseOrThrow(courseId);
|
||||
await assertCourseComplianceHistoryAccess(courseId, currentProfileId, targetProfileId);
|
||||
|
||||
const history = await getCourseComplianceHistoryRows(courseId, targetProfileId);
|
||||
|
||||
if (!history.member) {
|
||||
throw new AppError('Learner not found in this course', ErrorCodes.NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return {
|
||||
learner: {
|
||||
groupMemberId: history.member.id,
|
||||
profileId: history.member.profileId,
|
||||
fullname: history.profile?.fullname ?? null,
|
||||
username: history.profile?.username ?? null,
|
||||
avatarUrl: history.profile?.avatarUrl ?? null,
|
||||
email: history.profile?.email ?? history.member.email ?? null
|
||||
},
|
||||
currentRecord: history.records[0] ?? null,
|
||||
history: history.records
|
||||
};
|
||||
}
|
||||
|
||||
export async function resetCourseCompliance(courseId: string, payload: TCourseComplianceReset) {
|
||||
await getComplianceCourseOrThrow(courseId);
|
||||
ensureFutureOrPresentIsoDate(payload.dueDate, 'dueDate');
|
||||
|
||||
const students = await getTargetStudentMembers(courseId, payload.profileIds);
|
||||
const profileIds = students.flatMap((student) => (student.member.profileId ? [student.member.profileId] : []));
|
||||
const existingRecords = await getLatestComplianceRecordsByProfiles(courseId, profileIds);
|
||||
const recordByProfileId = new Map(existingRecords.map((record) => [record.profileId, record]));
|
||||
|
||||
let createdCount = 0;
|
||||
let updatedCount = 0;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (const student of students) {
|
||||
const profileId = student.member.profileId;
|
||||
if (!profileId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingRecord = recordByProfileId.get(profileId);
|
||||
if (!existingRecord) {
|
||||
await createCourseCompletionRecord(
|
||||
{
|
||||
courseId,
|
||||
groupMemberId: student.member.id,
|
||||
profileId,
|
||||
cycleNumber: 1,
|
||||
status: 'not_started',
|
||||
dueDate: payload.dueDate,
|
||||
attempts: 0,
|
||||
timeSpentMinutes: 0
|
||||
},
|
||||
tx
|
||||
);
|
||||
createdCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await updateCourseCompletionRecord(
|
||||
existingRecord.id,
|
||||
{
|
||||
status: 'not_started',
|
||||
dueDate: payload.dueDate,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
validUntil: null,
|
||||
expiredAt: null,
|
||||
score: null,
|
||||
attempts: 0,
|
||||
timeSpentMinutes: 0,
|
||||
waivedBy: null,
|
||||
waiverReason: null,
|
||||
waiverExpiresAt: null
|
||||
},
|
||||
tx
|
||||
);
|
||||
updatedCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
createdCount,
|
||||
updatedCount,
|
||||
learnerCount: students.length,
|
||||
dueDate: payload.dueDate
|
||||
};
|
||||
}
|
||||
|
||||
export async function extendCourseCompliance(courseId: string, payload: TCourseComplianceExtend) {
|
||||
await getComplianceCourseOrThrow(courseId);
|
||||
ensureFutureOrPresentIsoDate(payload.dueDate, 'dueDate');
|
||||
|
||||
const students = await getTargetStudentMembers(courseId, payload.profileIds);
|
||||
const profileIds = students.flatMap((student) => (student.member.profileId ? [student.member.profileId] : []));
|
||||
const existingRecords = await getLatestComplianceRecordsByProfiles(courseId, profileIds);
|
||||
const recordByProfileId = new Map(existingRecords.map((record) => [record.profileId, record]));
|
||||
|
||||
let createdCount = 0;
|
||||
let updatedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (const student of students) {
|
||||
const profileId = student.member.profileId;
|
||||
if (!profileId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingRecord = getCurrentIncompleteRecord(recordByProfileId.get(profileId));
|
||||
if (!existingRecord) {
|
||||
const latestRecord = recordByProfileId.get(profileId);
|
||||
if (latestRecord?.completedAt) {
|
||||
skippedCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await createCourseCompletionRecord(
|
||||
{
|
||||
courseId,
|
||||
groupMemberId: student.member.id,
|
||||
profileId,
|
||||
cycleNumber: latestRecord ? latestRecord.cycleNumber + 1 : 1,
|
||||
status: 'not_started',
|
||||
dueDate: payload.dueDate,
|
||||
attempts: 0,
|
||||
timeSpentMinutes: 0
|
||||
},
|
||||
tx
|
||||
);
|
||||
createdCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await updateCourseCompletionRecord(
|
||||
existingRecord.id,
|
||||
{
|
||||
dueDate: payload.dueDate
|
||||
},
|
||||
tx
|
||||
);
|
||||
updatedCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
createdCount,
|
||||
updatedCount,
|
||||
skippedCount,
|
||||
learnerCount: students.length,
|
||||
dueDate: payload.dueDate
|
||||
};
|
||||
}
|
||||
|
||||
export async function waiveCourseCompliance(courseId: string, actorProfileId: string, payload: TCourseComplianceWaive) {
|
||||
await getComplianceCourseOrThrow(courseId);
|
||||
|
||||
if (payload.waiverExpiresAt) {
|
||||
ensureFutureOrPresentIsoDate(payload.waiverExpiresAt, 'waiverExpiresAt');
|
||||
}
|
||||
|
||||
const students = await getTargetStudentMembers(courseId, payload.profileIds);
|
||||
const profileIds = students.flatMap((student) => (student.member.profileId ? [student.member.profileId] : []));
|
||||
const existingRecords = await getLatestComplianceRecordsByProfiles(courseId, profileIds);
|
||||
const recordByProfileId = new Map(existingRecords.map((record) => [record.profileId, record]));
|
||||
|
||||
let updatedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (const student of students) {
|
||||
const profileId = student.member.profileId;
|
||||
if (!profileId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingRecord = getCurrentIncompleteRecord(recordByProfileId.get(profileId));
|
||||
if (!existingRecord) {
|
||||
skippedCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await updateCourseCompletionRecord(
|
||||
existingRecord.id,
|
||||
{
|
||||
status: 'waived',
|
||||
waivedBy: actorProfileId,
|
||||
waiverReason: payload.waiverReason ?? null,
|
||||
waiverExpiresAt: payload.waiverExpiresAt ?? null
|
||||
},
|
||||
tx
|
||||
);
|
||||
updatedCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
updatedCount,
|
||||
skippedCount,
|
||||
learnerCount: students.length,
|
||||
waiverExpiresAt: payload.waiverExpiresAt ?? null
|
||||
};
|
||||
}
|
||||
|
||||
export async function runComplianceExpiryCheck() {
|
||||
const nowIso = new Date().toISOString();
|
||||
const records = await listComplianceRecordsReadyForExpiry(nowIso);
|
||||
|
||||
let processedCount = 0;
|
||||
let createdCycleCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
for (const record of records) {
|
||||
if (!record.validUntil) {
|
||||
skippedCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextDueDate = record.validUntil;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await updateCourseCompletionRecord(
|
||||
record.id,
|
||||
{
|
||||
expiredAt: nowIso
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
await updateCourseCertificateIssueStatusByRecordId(record.id, 'expired', tx);
|
||||
|
||||
const created = await createCourseCompletionRecord(
|
||||
{
|
||||
courseId: record.courseId,
|
||||
groupMemberId: record.groupMemberId,
|
||||
profileId: record.profileId,
|
||||
cycleNumber: record.cycleNumber + 1,
|
||||
status: 'not_started',
|
||||
dueDate: nextDueDate,
|
||||
attempts: 0,
|
||||
timeSpentMinutes: 0
|
||||
},
|
||||
tx
|
||||
).catch((error) => {
|
||||
if (error instanceof Error && error.message.includes('duplicate key value')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
|
||||
processedCount += 1;
|
||||
if (created) {
|
||||
createdCycleCount += 1;
|
||||
} else {
|
||||
skippedCount += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
processedCount,
|
||||
createdCycleCount,
|
||||
skippedCount
|
||||
};
|
||||
}
|
||||
|
||||
export async function runComplianceReminderScan() {
|
||||
const now = new Date();
|
||||
const rows = await listLatestComplianceRecordsForReminderScan();
|
||||
|
||||
let scannedCount = 0;
|
||||
let statusUpdatedCount = 0;
|
||||
let reminderEventCount = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
scannedCount += 1;
|
||||
|
||||
const compliance = row.course.compliance as ComplianceSettings | null;
|
||||
if (!compliance) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (row.record.status === 'waived') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dueDate = new Date(row.record.dueDate);
|
||||
if (Number.isNaN(dueDate.getTime())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const daysUntilDue = getWholeDaysUntil(now, dueDate);
|
||||
const gracePeriodDays = compliance.gracePeriodDays ?? 0;
|
||||
let nextStatus: ComplianceStatus | null = null;
|
||||
|
||||
if (daysUntilDue < 0) {
|
||||
const gracePeriodEndsAt = new Date(dueDate.getTime() + gracePeriodDays * 24 * 60 * 60 * 1000);
|
||||
nextStatus = gracePeriodDays > 0 && now <= gracePeriodEndsAt ? 'in_grace_period' : 'non_compliant';
|
||||
} else if ((compliance.reminderDaysBefore ?? []).includes(daysUntilDue)) {
|
||||
nextStatus = 'expiring_soon';
|
||||
|
||||
const createdEvent = await createCourseCompletionNotificationEvent({
|
||||
courseCompletionRecordId: row.record.id,
|
||||
channel: 'email',
|
||||
eventType: getReminderEventType(daysUntilDue)
|
||||
});
|
||||
|
||||
if (createdEvent) {
|
||||
reminderEventCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextStatus && row.record.status !== nextStatus) {
|
||||
await updateCourseCompletionRecord(row.record.id, { status: nextStatus });
|
||||
statusUpdatedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
scannedCount,
|
||||
statusUpdatedCount,
|
||||
reminderEventCount
|
||||
};
|
||||
}
|
||||
@@ -39,6 +39,26 @@ function buildContentKey(item: { id: string; type: string }) {
|
||||
return `${item.type}-${item.id}`;
|
||||
}
|
||||
|
||||
function normalizeDeleteItems<TItem extends { id: string; type: string }>(items: TItem[]): TItem[] {
|
||||
const seenKeys = new Set<string>();
|
||||
const uniqueItems: TItem[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const itemKey = buildContentKey(item);
|
||||
if (seenKeys.has(itemKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenKeys.add(itemKey);
|
||||
uniqueItems.push(item);
|
||||
}
|
||||
|
||||
const exercises = uniqueItems.filter((item) => item.type === ContentType.Exercise);
|
||||
const lessons = uniqueItems.filter((item) => item.type === ContentType.Lesson);
|
||||
|
||||
return [...exercises, ...lessons];
|
||||
}
|
||||
|
||||
async function getCourseContentMap(courseId: string) {
|
||||
const contentItems = await getCourseContentItems(courseId);
|
||||
const contentMap = new Map<string, CourseContentItemRow>();
|
||||
@@ -307,11 +327,14 @@ export async function deleteCourseContent(courseId: string, payload: TCourseCont
|
||||
}
|
||||
|
||||
const contentItems = await getCourseContentItems(courseId);
|
||||
const itemsToDelete = contentItems.filter(
|
||||
(item) =>
|
||||
(item.type === ContentType.Lesson || item.type === ContentType.Exercise) && item.sectionId === sectionId
|
||||
const itemsToDelete = normalizeDeleteItems(
|
||||
contentItems.filter(
|
||||
(item) =>
|
||||
(item.type === ContentType.Lesson || item.type === ContentType.Exercise) && item.sectionId === sectionId
|
||||
)
|
||||
);
|
||||
|
||||
console.log('Items to delete:', itemsToDelete);
|
||||
await db.transaction(async (tx) => {
|
||||
for (const item of itemsToDelete) {
|
||||
if (item.type === ContentType.Lesson) {
|
||||
@@ -324,6 +347,7 @@ export async function deleteCourseContent(courseId: string, payload: TCourseCont
|
||||
|
||||
const deleted = await deleteExercise(item.id, tx);
|
||||
if (!deleted) {
|
||||
console.log('delete exercise failed for id:', item.id);
|
||||
throw new AppError('Exercise not found', ErrorCodes.EXERCISE_NOT_FOUND, 404);
|
||||
}
|
||||
}
|
||||
@@ -343,9 +367,10 @@ export async function deleteCourseContent(courseId: string, payload: TCourseCont
|
||||
|
||||
const items = payload.items;
|
||||
await assertCourseContentItems(courseId, items);
|
||||
const normalizedItems = normalizeDeleteItems(items);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (const item of items) {
|
||||
for (const item of normalizedItems) {
|
||||
if (item.type === ContentType.Lesson) {
|
||||
const deleted = await deleteLesson(item.id, tx);
|
||||
if (!deleted) {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
|
||||
import { ContentType, ROLE } from '@cio/utils/constants';
|
||||
import type { TCourse } from '@cio/db/types';
|
||||
import type { TCourseCreate } from '@cio/utils/validation/course';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import * as schema from '@db/schema';
|
||||
import { exerciseBelongsToCourse } from '@cio/db/queries/course/certification-exercise';
|
||||
@@ -168,7 +169,7 @@ export async function getCourse(courseId?: string, slug?: string, profileId?: st
|
||||
*/
|
||||
export async function createCourse(
|
||||
profileId: string,
|
||||
data: { title: string; description: string; type: 'LIVE_CLASS' | 'SELF_PACED'; organizationId: string }
|
||||
data: TCourseCreate
|
||||
): Promise<{ course: TCourse; groupId: string; memberId: string }> {
|
||||
try {
|
||||
const description = sanitizeHtml(data.description);
|
||||
@@ -192,7 +193,8 @@ export async function createCourse(
|
||||
title: data.title,
|
||||
description,
|
||||
type: data.type,
|
||||
groupId: newGroup.id
|
||||
groupId: newGroup.id,
|
||||
compliance: data.compliance ? sanitizeUnknownStrings(data.compliance) : undefined
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import { getCourseById } from '@cio/db/queries/course';
|
||||
import { getCourseContentItems, type CourseContentItemRow } from '@cio/db/queries/course/content';
|
||||
import { getCourseOrganizationId } from '@cio/db/queries/tag';
|
||||
import { getOrganizationById } from '@cio/db/queries/organization';
|
||||
import type { TCourse, TOrganization } from '@cio/db/types';
|
||||
import { generateSlug } from '@cio/utils/functions';
|
||||
import { ContentType } from '@cio/utils/constants';
|
||||
import type { TCourseLandingPageUpdate } from '@cio/utils/validation/course';
|
||||
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { updateCourse } from './course';
|
||||
import { buildCourseBaseUrl, ensureCourseSlug, generateUniqueCourseSlug } from './landing-page';
|
||||
|
||||
export type CourseGoLiveIssue = {
|
||||
code: string;
|
||||
message: string;
|
||||
target?: string;
|
||||
};
|
||||
|
||||
export type CourseGoLiveReadiness = {
|
||||
ready: boolean;
|
||||
blockers: CourseGoLiveIssue[];
|
||||
warnings: CourseGoLiveIssue[];
|
||||
suggestedFixes: {
|
||||
slug?: string;
|
||||
landingPage?: TCourseLandingPageUpdate;
|
||||
};
|
||||
courseUrl?: string;
|
||||
};
|
||||
|
||||
type CourseReadinessInput = {
|
||||
course: Pick<
|
||||
TCourse,
|
||||
'id' | 'title' | 'description' | 'overview' | 'slug' | 'logo' | 'bannerImage' | 'metadata' | 'type' | 'cost'
|
||||
>;
|
||||
contentItems: CourseContentItemRow[];
|
||||
suggestedSlug?: string;
|
||||
organization?: Pick<TOrganization, 'customDomain' | 'isCustomDomainVerified' | 'siteName'> | null;
|
||||
};
|
||||
|
||||
const PLACEHOLDER_PATTERNS = [
|
||||
/\[edit me\]/i,
|
||||
/^untitled\b/i,
|
||||
/^new course$/i,
|
||||
/^course title$/i,
|
||||
/^course description$/i,
|
||||
/welcome to this amazing course/i
|
||||
];
|
||||
|
||||
function normalizeReadableText(value: string | null | undefined): string {
|
||||
return (value ?? '')
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/ | /gi, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function hasMeaningfulText(value: string | null | undefined): boolean {
|
||||
const normalized = normalizeReadableText(value);
|
||||
|
||||
if (normalized.length < 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(normalized));
|
||||
}
|
||||
|
||||
function buildIssue(code: string, message: string, target?: string): CourseGoLiveIssue {
|
||||
return target ? { code, message, target } : { code, message };
|
||||
}
|
||||
|
||||
function hasLessonContent(item: CourseContentItemRow): boolean {
|
||||
return Boolean(
|
||||
item.hasNoteContent || item.hasSlideContent || (item.videosCount ?? 0) > 0 || (item.documentsCount ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
function buildCourseUrl(
|
||||
organization: CourseReadinessInput['organization'],
|
||||
slug: string | null | undefined
|
||||
): string | undefined {
|
||||
if (!organization || !slug) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `${buildCourseBaseUrl(organization)}/course/${encodeURIComponent(slug)}`;
|
||||
}
|
||||
|
||||
export function evaluateCourseGoLiveReadiness(input: CourseReadinessInput): CourseGoLiveReadiness {
|
||||
const { course, contentItems, organization, suggestedSlug } = input;
|
||||
const blockers: CourseGoLiveIssue[] = [];
|
||||
const warnings: CourseGoLiveIssue[] = [];
|
||||
const landingPageFixes: TCourseLandingPageUpdate = {};
|
||||
const metadataFixes: NonNullable<TCourseLandingPageUpdate['metadata']> = {};
|
||||
|
||||
if (!hasMeaningfulText(course.title)) {
|
||||
blockers.push(buildIssue('COURSE_TITLE_MISSING', 'Add a clear course title.', 'course.title'));
|
||||
}
|
||||
|
||||
if (!hasMeaningfulText(course.description)) {
|
||||
blockers.push(buildIssue('COURSE_DESCRIPTION_MISSING', 'Add a clear course description.', 'course.description'));
|
||||
}
|
||||
|
||||
if (!course.slug && !suggestedSlug) {
|
||||
blockers.push(buildIssue('COURSE_SLUG_MISSING', 'Generate a public course slug.', 'course.slug'));
|
||||
}
|
||||
|
||||
if (!hasMeaningfulText(course.overview)) {
|
||||
blockers.push(buildIssue('LANDING_OVERVIEW_MISSING', 'Add a landing-page overview.', 'course.overview'));
|
||||
landingPageFixes.overview = '';
|
||||
}
|
||||
|
||||
if (!hasMeaningfulText(course.metadata?.description)) {
|
||||
blockers.push(
|
||||
buildIssue('LANDING_DESCRIPTION_MISSING', 'Add a landing-page description.', 'course.metadata.description')
|
||||
);
|
||||
metadataFixes.description = '';
|
||||
}
|
||||
|
||||
if (!hasMeaningfulText(course.metadata?.goals)) {
|
||||
blockers.push(
|
||||
buildIssue('LANDING_GOALS_MISSING', 'Add course goals for the landing page.', 'course.metadata.goals')
|
||||
);
|
||||
metadataFixes.goals = '';
|
||||
}
|
||||
|
||||
if (!hasMeaningfulText(course.metadata?.requirements)) {
|
||||
blockers.push(
|
||||
buildIssue(
|
||||
'LANDING_REQUIREMENTS_MISSING',
|
||||
'Add course requirements for the landing page.',
|
||||
'course.metadata.requirements'
|
||||
)
|
||||
);
|
||||
metadataFixes.requirements = '';
|
||||
}
|
||||
|
||||
if (!course.logo && !course.bannerImage) {
|
||||
blockers.push(buildIssue('LANDING_IMAGE_MISSING', 'Add a landing-page banner or course image.', 'course.logo'));
|
||||
landingPageFixes.generateImage = true;
|
||||
}
|
||||
|
||||
const learningItems = contentItems.filter(
|
||||
(item) => item.type === ContentType.Lesson || item.type === ContentType.Exercise
|
||||
);
|
||||
|
||||
if (learningItems.length === 0) {
|
||||
blockers.push(buildIssue('COURSE_CONTENT_MISSING', 'Add at least one lesson or exercise.', 'course.content'));
|
||||
}
|
||||
|
||||
for (const item of contentItems) {
|
||||
if (item.type === ContentType.Lesson && !hasLessonContent(item)) {
|
||||
blockers.push(
|
||||
buildIssue(
|
||||
'LESSON_CONTENT_EMPTY',
|
||||
`Add content to lesson "${item.title ?? 'Untitled lesson'}".`,
|
||||
`lesson:${item.id}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === ContentType.Exercise && (item.questionCount ?? 0) === 0) {
|
||||
blockers.push(
|
||||
buildIssue(
|
||||
'EXERCISE_QUESTIONS_MISSING',
|
||||
`Add at least one question to exercise "${item.title ?? 'Untitled exercise'}".`,
|
||||
`exercise:${item.id}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasMeaningfulText(course.metadata?.instructor?.name)) {
|
||||
warnings.push(
|
||||
buildIssue('INSTRUCTOR_MISSING', 'Add instructor information for more trust.', 'course.metadata.instructor')
|
||||
);
|
||||
}
|
||||
|
||||
if (!course.metadata?.reviews?.some((review) => !review.hide && hasMeaningfulText(review.description))) {
|
||||
warnings.push(
|
||||
buildIssue('REVIEWS_MISSING', 'Add testimonials or reviews when available.', 'course.metadata.reviews')
|
||||
);
|
||||
}
|
||||
|
||||
if ((course.cost ?? 0) > 0 && !hasMeaningfulText(course.metadata?.paymentLink)) {
|
||||
warnings.push(
|
||||
buildIssue('PAYMENT_LINK_MISSING', 'Add a payment link for this paid course.', 'course.metadata.paymentLink')
|
||||
);
|
||||
}
|
||||
|
||||
if (course.type === 'COMPLIANCE') {
|
||||
warnings.push(
|
||||
buildIssue('COMPLIANCE_REVIEW_RECOMMENDED', 'Review compliance settings before publishing.', 'course.compliance')
|
||||
);
|
||||
}
|
||||
|
||||
if (Object.keys(metadataFixes).length > 0) {
|
||||
landingPageFixes.metadata = metadataFixes;
|
||||
}
|
||||
|
||||
const suggestedFixes: CourseGoLiveReadiness['suggestedFixes'] = {};
|
||||
if (!course.slug && suggestedSlug) {
|
||||
suggestedFixes.slug = suggestedSlug;
|
||||
}
|
||||
|
||||
if (Object.keys(landingPageFixes).length > 0) {
|
||||
suggestedFixes.landingPage = landingPageFixes;
|
||||
}
|
||||
|
||||
return {
|
||||
ready: blockers.length === 0,
|
||||
blockers,
|
||||
warnings,
|
||||
suggestedFixes,
|
||||
courseUrl: buildCourseUrl(organization, course.slug ?? suggestedSlug)
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCourseGoLiveReadiness(courseId: string): Promise<CourseGoLiveReadiness> {
|
||||
const [course] = await getCourseById(courseId);
|
||||
if (!course) {
|
||||
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const organizationId = await getCourseOrganizationId(courseId);
|
||||
const [contentItems, organization] = await Promise.all([
|
||||
getCourseContentItems(courseId),
|
||||
organizationId ? getOrganizationById(organizationId) : Promise.resolve(null)
|
||||
]);
|
||||
|
||||
const suggestedSlug = course.slug
|
||||
? undefined
|
||||
: await generateUniqueCourseSlug(generateSlug(course.title, { fallback: 'course' }));
|
||||
|
||||
return evaluateCourseGoLiveReadiness({
|
||||
course,
|
||||
contentItems,
|
||||
suggestedSlug,
|
||||
organization
|
||||
});
|
||||
}
|
||||
|
||||
export async function publishCourseWhenReady(courseId: string) {
|
||||
const readiness = await getCourseGoLiveReadiness(courseId);
|
||||
if (!readiness.ready) {
|
||||
const blockerList = readiness.blockers.map((blocker) => blocker.message).join(' ');
|
||||
throw new AppError(`Course is not ready to go live. ${blockerList}`, 'COURSE_GO_LIVE_BLOCKED', 400);
|
||||
}
|
||||
|
||||
const [course] = await getCourseById(courseId);
|
||||
if (!course) {
|
||||
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const slug = await ensureCourseSlug(courseId, course.title);
|
||||
const publishedCourse = await updateCourse(courseId, { slug, isPublished: true });
|
||||
const publishedReadiness = await getCourseGoLiveReadiness(courseId);
|
||||
|
||||
return {
|
||||
course: publishedCourse,
|
||||
readiness: publishedReadiness
|
||||
};
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { getProfileById } from '@cio/db/queries/auth';
|
||||
import { buildEmailFromName, sendEmail } from '@cio/email';
|
||||
import { getProfileByEmail, markUserAndProfileEmailVerified } from '@cio/db/queries/auth';
|
||||
import { generateSlug } from '@cio/utils/functions';
|
||||
import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance';
|
||||
|
||||
type InviteStatus = 'ACTIVE' | 'EXPIRED' | 'USED_UP' | 'REVOKED';
|
||||
type InvitePreset = 'ONE_TIME_24H' | 'MULTI_USE_7D' | 'MULTI_USE_30D' | 'CUSTOM';
|
||||
@@ -647,6 +648,8 @@ export async function enrollInCourse(
|
||||
.limit(1);
|
||||
|
||||
if (existingMember) {
|
||||
await ensureComplianceEnrollmentRecordsForProfiles([courseId], [user.id]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
alreadyJoined: true,
|
||||
@@ -677,6 +680,8 @@ export async function enrollInCourse(
|
||||
email: normalizedEmail
|
||||
});
|
||||
|
||||
await ensureComplianceEnrollmentRecordsForProfiles([courseId], [user.id]);
|
||||
|
||||
await sendStudentJoinEmails({
|
||||
courseId,
|
||||
courseName: title,
|
||||
@@ -1071,6 +1076,8 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
|
||||
};
|
||||
});
|
||||
|
||||
await ensureComplianceEnrollmentRecordsForProfiles([result.courseId], [user.id]);
|
||||
|
||||
if (!result.alreadyJoined) {
|
||||
await sendStudentJoinEmails({
|
||||
courseId: result.courseId,
|
||||
|
||||
@@ -20,7 +20,7 @@ type CourseLandingPageUpdateResult = {
|
||||
bannerImageUrl: string | null;
|
||||
};
|
||||
|
||||
async function generateUniqueCourseSlug(baseSlug: string): Promise<string> {
|
||||
export async function generateUniqueCourseSlug(baseSlug: string): Promise<string> {
|
||||
let suffix = 0;
|
||||
let candidate = baseSlug;
|
||||
|
||||
@@ -40,7 +40,7 @@ async function generateUniqueCourseSlug(baseSlug: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCourseSlug(courseId: string, title: string | null | undefined): Promise<string> {
|
||||
export async function ensureCourseSlug(courseId: string, title: string | null | undefined): Promise<string> {
|
||||
const [course] = await getCourseById(courseId);
|
||||
if (course?.slug) {
|
||||
return course.slug;
|
||||
@@ -52,7 +52,9 @@ async function ensureCourseSlug(courseId: string, title: string | null | undefin
|
||||
return uniqueSlug;
|
||||
}
|
||||
|
||||
function buildCourseBaseUrl(organization: TOrganization) {
|
||||
export function buildCourseBaseUrl(
|
||||
organization: Pick<TOrganization, 'customDomain' | 'isCustomDomainVerified' | 'siteName'>
|
||||
) {
|
||||
if (organization.customDomain && organization.isCustomDomainVerified) {
|
||||
return `https://${organization.customDomain}`;
|
||||
}
|
||||
@@ -60,7 +62,7 @@ function buildCourseBaseUrl(organization: TOrganization) {
|
||||
return getDashboardBaseUrl(organization.siteName ?? undefined);
|
||||
}
|
||||
|
||||
async function resolveCourseUrl(organizationId: string, courseId: string, title: string) {
|
||||
export async function resolveCourseUrl(organizationId: string, courseId: string, title: string) {
|
||||
const organization = await getOrganizationById(organizationId);
|
||||
if (!organization) {
|
||||
throw new AppError('Organization not found', ErrorCodes.ORG_NOT_FOUND, 404);
|
||||
|
||||
@@ -15,6 +15,7 @@ import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
||||
import { getCourseWithOrgData } from '@cio/db/queries/course';
|
||||
import { getProfileById } from '@cio/db/queries/auth';
|
||||
import { buildEmailFromName, sendEmail } from '@cio/email';
|
||||
import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance';
|
||||
|
||||
/**
|
||||
* Gets all course members (people) for a course
|
||||
@@ -53,6 +54,10 @@ export async function addMember(
|
||||
|
||||
const addedMember = await addCourseMember(courseId, data);
|
||||
|
||||
if (data.roleId === ROLE.STUDENT && data.profileId) {
|
||||
await ensureComplianceEnrollmentRecordsForProfiles([courseId], [data.profileId]);
|
||||
}
|
||||
|
||||
// Send emails when a student joins
|
||||
if (data.roleId === ROLE.STUDENT) {
|
||||
try {
|
||||
@@ -176,6 +181,13 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) {
|
||||
|
||||
// Add all members
|
||||
const addedMembers = await Promise.all(members.map((member) => addCourseMember(courseId, member)));
|
||||
const studentProfileIds = members
|
||||
.filter((member) => member.roleId === ROLE.STUDENT && member.profileId)
|
||||
.map((member) => member.profileId!);
|
||||
|
||||
if (studentProfileIds.length > 0) {
|
||||
await ensureComplianceEnrollmentRecordsForProfiles([courseId], studentProfileIds);
|
||||
}
|
||||
|
||||
// Send welcome emails for teachers (TUTOR role = 2, ADMIN role = 1)
|
||||
const baseUrl = getDashboardBaseUrl(orgSiteName);
|
||||
|
||||
Reference in New Issue
Block a user