feat: certification rules, content sanitization, handle draft course updates and reset content, better redis logging

This commit is contained in:
Rotimi Best
2026-03-30 08:24:55 +01:00
parent ff2ad59377
commit 5a8f50f67b
131 changed files with 5643 additions and 2134 deletions
@@ -0,0 +1,80 @@
import * as schema from '@db/schema';
import { and, asc, eq, or, sql } from 'drizzle-orm';
import { db } from '@db/drizzle';
/**
* Whether an exercise is part of a course (direct course_id or via lesson).
*/
export async function exerciseBelongsToCourse(exerciseId: string, courseId: string): Promise<boolean> {
try {
const [row] = await db
.select({ id: schema.exercise.id })
.from(schema.exercise)
.leftJoin(schema.lesson, eq(schema.exercise.lessonId, schema.lesson.id))
.where(
and(
eq(schema.exercise.id, exerciseId),
or(eq(schema.exercise.courseId, courseId), eq(schema.lesson.courseId, courseId))
)
)
.limit(1);
return !!row;
} catch (error) {
console.error('exerciseBelongsToCourse error:', error);
throw new Error(
`Failed to check exercise belongs to course: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function getExerciseTitleAndMaxPoints(exerciseId: string): Promise<{ title: string; maxPoints: number }> {
try {
const [ex] = await db
.select({ title: schema.exercise.title })
.from(schema.exercise)
.where(eq(schema.exercise.id, exerciseId))
.limit(1);
if (!ex) {
return { title: '', maxPoints: 0 };
}
const [sumRow] = await db
.select({
maxPoints: sql<number>`coalesce(sum(${schema.question.points}), 0)::float`
})
.from(schema.question)
.where(eq(schema.question.exerciseId, exerciseId));
return { title: ex.title ?? '', maxPoints: Number(sumRow?.maxPoints ?? 0) };
} catch (error) {
console.error('getExerciseTitleAndMaxPoints error:', error);
throw new Error(
`Failed to get exercise title and max points: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function getStudentSubmissionsForExercise(
groupMemberId: string,
exerciseId: string
): Promise<Array<{ total: number | null; gradingState: string | null }>> {
try {
return db
.select({
total: schema.submission.total,
gradingState: schema.submission.gradingState
})
.from(schema.submission)
.where(and(eq(schema.submission.exerciseId, exerciseId), eq(schema.submission.submittedBy, groupMemberId)))
.orderBy(asc(schema.submission.createdAt));
} catch (error) {
console.error('getStudentSubmissionsForExercise error:', error);
throw new Error(
`Failed to list submissions for exercise: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
+98 -49
View File
@@ -426,11 +426,20 @@ export async function getCourseProgress(
lessonsCompleted: number;
exercisesCount: number;
exercisesCompleted: number;
groupMemberId: string | null;
roleId: number | null;
certificateEarnedAt: string | null;
certificationEmailSentAt: string | null;
}> {
try {
// Get the groupmember ID for this profile in the course
const groupMemberResult = await db
.select({ id: schema.groupmember.id })
.select({
id: schema.groupmember.id,
roleId: schema.groupmember.roleId,
certificateEarnedAt: schema.groupmember.certificateEarnedAt,
certificationEmailSentAt: schema.groupmember.certificationEmailSentAt
})
.from(schema.groupmember)
.innerJoin(schema.course, eq(schema.course.groupId, schema.groupmember.groupId))
.where(and(eq(schema.course.id, courseId), eq(schema.groupmember.profileId, profileId)))
@@ -442,64 +451,64 @@ export async function getCourseProgress(
lessonsCount: 0,
lessonsCompleted: 0,
exercisesCount: 0,
exercisesCompleted: 0
exercisesCompleted: 0,
groupMemberId: null,
roleId: null,
certificateEarnedAt: null,
certificationEmailSentAt: null
};
}
const groupMemberId = groupMemberResult[0].id;
const roleId = groupMemberResult[0].roleId;
const certificateEarnedAt = groupMemberResult[0].certificateEarnedAt ?? null;
const certificationEmailSentAt = groupMemberResult[0].certificationEmailSentAt ?? null;
// Get all lessons for the course
const lessons = await db
.select({ id: schema.lesson.id })
.from(schema.lesson)
.where(eq(schema.lesson.courseId, courseId));
const [lessons, completedLessons, exercises, [completedRow]] = await Promise.all([
db.select({ id: schema.lesson.id }).from(schema.lesson).where(eq(schema.lesson.courseId, courseId)),
const lessonsCount = lessons.length;
db
.select({ id: schema.lessonCompletion.id })
.from(schema.lessonCompletion)
.innerJoin(schema.lesson, eq(schema.lessonCompletion.lessonId, schema.lesson.id))
.where(
and(
eq(schema.lesson.courseId, courseId),
eq(schema.lessonCompletion.profileId, profileId),
eq(schema.lessonCompletion.isComplete, true)
)
),
// Get completed lessons (lesson_completion where is_complete = true and profile_id matches)
const completedLessons = await db
.select({ id: schema.lessonCompletion.id })
.from(schema.lessonCompletion)
.innerJoin(schema.lesson, eq(schema.lessonCompletion.lessonId, schema.lesson.id))
.where(
and(
eq(schema.lesson.courseId, courseId),
eq(schema.lessonCompletion.profileId, profileId),
eq(schema.lessonCompletion.isComplete, true)
db
.select({ id: schema.exercise.id })
.from(schema.exercise)
.leftJoin(schema.lesson, eq(schema.exercise.lessonId, schema.lesson.id))
.where(or(eq(schema.exercise.courseId, courseId), eq(schema.lesson.courseId, courseId))),
db
.select({
exercisesCompleted: sql<number>`count(distinct ${schema.exercise.id})::int`
})
.from(schema.submission)
.innerJoin(schema.exercise, eq(schema.submission.exerciseId, schema.exercise.id))
.leftJoin(schema.lesson, eq(schema.exercise.lessonId, schema.lesson.id))
.where(
and(
or(eq(schema.exercise.courseId, courseId), eq(schema.lesson.courseId, courseId)),
eq(schema.submission.submittedBy, groupMemberId)
)
)
);
const lessonsCompleted = completedLessons.length;
// Get all exercises for the course (legacy lesson_id support)
const exercises = await db
.select({ id: schema.exercise.id })
.from(schema.exercise)
.leftJoin(schema.lesson, eq(schema.exercise.lessonId, schema.lesson.id))
.where(or(eq(schema.exercise.courseId, courseId), eq(schema.lesson.courseId, courseId)));
const exercisesCount = exercises.length;
// Get completed exercises (submissions where submitted_by = groupmember.id)
const completedExercises = await db
.select({ id: schema.submission.id })
.from(schema.submission)
.innerJoin(schema.exercise, eq(schema.submission.exerciseId, schema.exercise.id))
.leftJoin(schema.lesson, eq(schema.exercise.lessonId, schema.lesson.id))
.where(
and(
or(eq(schema.exercise.courseId, courseId), eq(schema.lesson.courseId, courseId)),
eq(schema.submission.submittedBy, groupMemberId)
)
);
const exercisesCompleted = completedExercises.length;
]);
return {
lessonsCount,
lessonsCompleted,
exercisesCount,
exercisesCompleted
lessonsCount: lessons.length,
lessonsCompleted: completedLessons.length,
exercisesCount: exercises.length,
exercisesCompleted: completedRow?.exercisesCompleted ?? 0,
groupMemberId,
roleId,
certificateEarnedAt,
certificationEmailSentAt
};
} catch (error) {
console.error('getCourseProgress error:', error);
@@ -507,6 +516,46 @@ export async function getCourseProgress(
}
}
/** Course + org fields needed for certification email and threshold rules */
export type TCourseCertificationRow = {
certificate: {
isDownloadable?: boolean;
theme?: string;
deadline?: string | null;
threshold?: number;
requiredExerciseId?: string | null;
exerciseMinScorePercent?: number | null;
emailMessage?: string | null;
} | null;
title: string;
orgSiteName: string | null;
orgName: string;
};
export async function getCourseCertificationRow(courseId: string): Promise<TCourseCertificationRow | null> {
try {
const [row] = await db
.select({
certificate: schema.course.certificate,
title: schema.course.title,
orgSiteName: schema.organization.siteName,
orgName: schema.organization.name
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.innerJoin(schema.organization, eq(schema.group.organizationId, schema.organization.id))
.where(eq(schema.course.id, courseId))
.limit(1);
return row ?? null;
} catch (error) {
console.error('getCourseCertificationRow error:', error);
throw new Error(
`Failed to get course certification row: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
interface GetOrgCoursesOptions {
/** Organization ID (required) */
orgId: string;
+36
View File
@@ -307,3 +307,39 @@ export async function getProfileByGroupMemberId(groupMemberId: string): Promise<
);
}
}
/**
* Sets when the learner first met certification threshold for this course membership.
*/
export async function setMemberCertificateEarned(memberId: string, earnedAt: string): Promise<TGroupmember | null> {
try {
const [updated] = await db
.update(schema.groupmember)
.set({ certificateEarnedAt: earnedAt })
.where(eq(schema.groupmember.id, memberId))
.returning();
return updated ?? null;
} catch (error) {
console.error('setMemberCertificateEarned error:', error);
throw new Error(`Failed to set certificate earned: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Records that the certification congratulations email was sent.
*/
export async function setMemberCertificationEmailSent(memberId: string, sentAt: string): Promise<TGroupmember | null> {
try {
const [updated] = await db
.update(schema.groupmember)
.set({ certificationEmailSentAt: sentAt })
.where(eq(schema.groupmember.id, memberId))
.returning();
return updated ?? null;
} catch (error) {
console.error('setMemberCertificationEmailSent error:', error);
throw new Error(
`Failed to set certification email sent: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
@@ -8,10 +8,11 @@ import type {
TOrganization,
TOrganizationPlan
} from '@db/types';
import { and, eq, inArray, or, sql } from 'drizzle-orm';
import { and, asc, count, desc, eq, ilike, inArray, or, sql } from 'drizzle-orm';
import { ROLE } from '@cio/utils/constants';
import { db, type DbOrTxClient } from '@db/drizzle';
import type { TAudienceSortBy, TAudienceSortOrder } from '@cio/utils/validation/organization';
export function getOrgIdBySiteName(siteName: string) {
return db.select().from(schema.organization).where(eq(schema.organization.siteName, siteName)).limit(1);
@@ -246,6 +247,27 @@ export const deleteOrganizationMember = async (orgId: string, memberId: number)
return deleted || null;
};
/**
* Deletes a student organization member by ID
* @param orgId Organization ID
* @param memberId Member ID to delete
* @returns Deleted member or null if not found
*/
export const deleteOrganizationAudienceMember = async (orgId: string, memberId: number) => {
const [deleted] = await db
.delete(schema.organizationmember)
.where(
and(
eq(schema.organizationmember.organizationId, orgId),
eq(schema.organizationmember.id, memberId),
eq(schema.organizationmember.roleId, ROLE.STUDENT)
)
)
.returning();
return deleted || null;
};
/**
* Checks if a user is an admin of an organization
* @param orgId Organization ID
@@ -349,38 +371,93 @@ export const getOrganizationTeam = async (orgId: string) => {
* Includes invited members without a profile (LEFT JOIN profile).
* Row id is organizationmember.id; use profileId for profile-backed actions when present.
*/
export const getOrganizationAudience = async (orgId: string) => {
type GetOrganizationAudienceOptions = {
page?: number;
limit?: number;
search?: string;
sortBy?: TAudienceSortBy;
sortOrder?: TAudienceSortOrder;
};
export const getOrganizationAudience = async (orgId: string, options: GetOrganizationAudienceOptions = {}) => {
const page = options.page && options.page > 0 ? options.page : 1;
const limit = options.limit && options.limit > 0 ? Math.min(options.limit, 100) : 20;
const offset = (page - 1) * limit;
const search = options.search?.trim();
const sortBy = options.sortBy ?? 'createdAt';
const sortOrder = options.sortOrder ?? 'desc';
const audienceNameSql = sql<string>`COALESCE(NULLIF(${schema.profile.fullname}, ''), ${schema.profile.email}, ${schema.organizationmember.email})`;
const audienceEmailSql = sql<string>`COALESCE(${schema.profile.email}, ${schema.organizationmember.email})`;
const audienceCreatedAtSql = sql<string>`COALESCE(${schema.profile.createdAt}, ${schema.organizationmember.createdAt})`;
const conditions = [
eq(schema.organizationmember.organizationId, orgId),
eq(schema.organizationmember.roleId, ROLE.STUDENT)
];
if (search) {
const searchValue = `%${search}%`;
conditions.push(
or(
ilike(schema.profile.fullname, searchValue),
ilike(schema.profile.email, searchValue),
ilike(schema.organizationmember.email, searchValue)
)!
);
}
const whereClause = and(...conditions);
const [totalRow] = await db
.select({ count: count(schema.organizationmember.id) })
.from(schema.organizationmember)
.leftJoin(schema.profile, eq(schema.organizationmember.profileId, schema.profile.id))
.where(whereClause);
const total = Number(totalRow?.count ?? 0);
const orderByExpression =
sortBy === 'name' ? audienceNameSql : sortBy === 'email' ? audienceEmailSql : audienceCreatedAtSql;
const orderedExpression = sortOrder === 'asc' ? asc(orderByExpression) : desc(orderByExpression);
const result = await db
.select({
memberId: schema.organizationmember.id,
profileId: schema.profile.id,
fullname: schema.profile.fullname,
email: sql<string>`COALESCE(${schema.profile.email}, ${schema.organizationmember.email})`.as('email'),
email: audienceEmailSql.as('email'),
avatarUrl: schema.profile.avatarUrl,
profileCreatedAt: schema.profile.createdAt,
memberCreatedAt: schema.organizationmember.createdAt
})
.from(schema.organizationmember)
.leftJoin(schema.profile, eq(schema.organizationmember.profileId, schema.profile.id))
.where(
and(eq(schema.organizationmember.organizationId, orgId), eq(schema.organizationmember.roleId, ROLE.STUDENT))
);
.where(whereClause)
.orderBy(orderedExpression, desc(schema.organizationmember.id))
.limit(limit)
.offset(offset);
return result.map((row) => {
const email = row.email?.trim() ?? '';
const name = row.fullname?.trim() || (email.includes('@') ? email.split('@')[0] : email) || '';
const createdAtRaw = row.profileId ? row.profileCreatedAt : row.memberCreatedAt;
const createdAt = createdAtRaw ? new Date(createdAtRaw).toDateString() : '';
return {
items: result.map((row) => {
const email = row.email?.trim() ?? '';
const name = row.fullname?.trim() || (email.includes('@') ? email.split('@')[0] : email) || '';
const createdAtRaw = row.profileId ? row.profileCreatedAt : row.memberCreatedAt;
const createdAt = createdAtRaw ? new Date(createdAtRaw).toDateString() : '';
return {
id: row.memberId,
profileId: row.profileId ?? null,
name,
email,
avatarUrl: row.avatarUrl || '',
createdAt
};
});
return {
id: row.memberId,
profileId: row.profileId ?? null,
name,
email,
avatarUrl: row.avatarUrl || '',
createdAt
};
}),
page,
limit,
total,
totalPages: Math.max(1, Math.ceil(total / limit))
};
};
/**
@@ -760,7 +837,8 @@ export async function getOrgMembersByProfileIds(orgId: string, profileIds: strin
return db
.select({
profileId: schema.organizationmember.profileId,
email: schema.organizationmember.email
email: schema.organizationmember.email,
roleId: schema.organizationmember.roleId
})
.from(schema.organizationmember)
.where(
@@ -776,3 +854,51 @@ export async function getOrgMembersByProfileIds(orgId: string, profileIds: strin
);
}
}
export type TOrganizationMemberByEmail = {
normalizedEmail: string;
profileId: string | null;
roleId: number;
};
/**
* Resolves organization members matching normalized emails (profile email or organizationmember.email).
*/
export async function getOrganizationMembersByNormalizedEmails(
orgId: string,
emails: string[]
): Promise<TOrganizationMemberByEmail[]> {
try {
if (emails.length === 0) return [];
const normalized = [...new Set(emails.map((e) => e.toLowerCase().trim()).filter(Boolean))];
const rows = await db
.select({
roleId: schema.organizationmember.roleId,
profileId: schema.organizationmember.profileId,
matchEmail: sql<string>`lower(trim(coalesce(${schema.profile.email}, ${schema.organizationmember.email})))`.as(
'matchEmail'
)
})
.from(schema.organizationmember)
.leftJoin(schema.profile, eq(schema.organizationmember.profileId, schema.profile.id))
.where(
and(
eq(schema.organizationmember.organizationId, orgId),
inArray(sql`lower(trim(coalesce(${schema.profile.email}, ${schema.organizationmember.email})))`, normalized)
)
);
return rows.map((row) => ({
normalizedEmail: row.matchEmail,
profileId: row.profileId,
roleId: row.roleId
}));
} catch (error) {
console.error('getOrganizationMembersByNormalizedEmails error:', error);
throw new Error(
`Failed to get organization members by emails: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
@@ -1,7 +1,7 @@
import * as schema from '@db/schema';
import type { TNewQuestionAnswer, TNewSubmission, TQuestionAnswer, TSubmission } from '@db/types';
import { and, db, eq, inArray } from '@db/drizzle';
import { and, count, db, eq, inArray, sql } from '@db/drizzle';
/**
* Gets a submission by ID
@@ -24,6 +24,23 @@ export async function getSubmissionById(submissionId: string): Promise<TSubmissi
}
}
export async function hasSubmission(exerciseId: string, submittedBy: string): Promise<boolean> {
try {
const [result] = await db
.select({ total: count() })
.from(schema.submission)
.where(and(eq(schema.submission.exerciseId, exerciseId), eq(schema.submission.submittedBy, submittedBy)))
.limit(1);
return (result?.total ?? 0) > 0;
} catch (error) {
console.error('hasSubmission error:', error);
throw new Error(
`Failed to check submission existence: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Gets submissions by course ID with full related data (answers, profile, etc.)
* @param courseId Course ID
@@ -266,6 +283,21 @@ export async function upsertQuestionAnswer(data: TNewQuestionAnswer): Promise<TQ
}
}
export async function insertQuestionAnswersBatch(rows: TNewQuestionAnswer[]): Promise<TQuestionAnswer[]> {
if (rows.length === 0) {
return [];
}
try {
return db.insert(schema.questionAnswer).values(rows).returning();
} catch (error) {
console.error('insertQuestionAnswersBatch error:', error);
throw new Error(
`Failed to insert question answers batch: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Updates a question answer
* @param submissionId Submission ID
@@ -311,13 +343,15 @@ export async function updateSubmissionGrades(
): Promise<TSubmission | null> {
try {
return await db.transaction(async (tx) => {
for (const { questionId, points } of data.answers) {
await tx
.update(schema.questionAnswer)
.set({ point: points })
.where(
and(eq(schema.questionAnswer.submissionId, submissionId), eq(schema.questionAnswer.questionId, questionId))
);
if (data.answers.length > 0) {
const valueRows = data.answers.map((a) => sql`(${a.questionId}::bigint, ${a.points}::bigint)`);
await tx.execute(sql`
UPDATE question_answer AS qa
SET point = v.point
FROM (VALUES ${sql.join(valueRows, sql`, `)}) AS v(question_id, point)
WHERE qa.submission_id = ${submissionId}
AND qa.question_id = v.question_id
`);
}
const submissionData: Partial<TSubmission> = {