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> = {
+9 -7
View File
@@ -587,13 +587,15 @@ export const course = pgTable(
currency: varchar().default('USD').notNull(),
bannerImage: text('banner_image'),
isPublished: boolean('is_published').default(false),
isCertificateDownloadable: boolean('is_certificate_downloadable').default(false),
certificateTheme: text('certificate_theme'),
certificationDeadline: timestamp('certification_deadline', { withTimezone: true, mode: 'string' }),
certificationThreshold: integer('certification_threshold').default(100),
certificationRequiredExerciseId: uuid('certification_required_exercise_id'),
certificationExerciseMinScorePercent: integer('certification_exercise_min_score_percent'),
certificationEmailMessage: text('certification_email_message'),
certificate: jsonb().default({}).$type<{
isDownloadable?: boolean;
theme?: string;
deadline?: string | null;
threshold?: number;
requiredExerciseId?: string | null;
exerciseMinScorePercent?: number | null;
emailMessage?: string | null;
}>(),
status: text().default('ACTIVE').notNull(),
type: courseType().default('LIVE_CLASS')
},
+20 -2
View File
@@ -10,6 +10,7 @@ import { seedGroupmembers } from '@db/utils/seed/groupmember';
import { type LessonTemplate, seedLessons } from '@db/utils/seed/lesson';
import { seedOrganization } from '@db/utils/seed/organization';
import { seedOrganizationMember } from '@db/utils/seed/organizationmember';
import { seedEnterpriseOrganizationPlan } from '@db/utils/seed/organizationPlan';
import { seedProfile } from '@db/utils/seed/profile';
import { seedQuestionTypes } from '@db/utils/seed/questionType';
import { seedQuestions } from '@db/utils/seed/question';
@@ -28,6 +29,9 @@ const MVC_COURSE_ID = '98e6e798-f0bd-4f9d-a6f5-ce0816a4f97e';
const REACT_COURSE_ID = '16e3bc8d-5d1b-4708-988e-93abae288ccf';
const PANDAS_COURSE_ID = 'f0a85d18-aff4-412f-b8e6-3b34ef098dce';
const STUDENT_USER_ID = '0c256e75-aa40-4f62-8d30-0217ca1c60d9';
const ENTERPRISE_ORG_ID = '2b8f4a1c-6d3e-4b2a-9f7e-1c4d8a6e9b0f';
const ENTERPRISE_ADMIN_USER_ID = '3c9052de-7e4f-4c3a-8d8e-2d5e9b7f1a3c';
const ENTERPRISE_STUDENT_USER_ID = '4da163ef-8050-4d4b-9c9f-3e6fac802b4d';
// Parse command line arguments
function parseFlags(): Set<string> {
@@ -62,6 +66,7 @@ Flags:
--accounts Seed accounts
--organizations Seed organizations
--organization-members Seed organization members
--organization-plan Seed enterprise plan for Coursera Test org
--groups Seed groups
--group-members Seed group members
--courses Seed courses
@@ -108,11 +113,22 @@ const seedFunctions = {
},
organizations: async () => {
console.log('📝 Seeding organizations...');
await seedOrganization({ testOrgId: TEST_ORG_ID });
await seedOrganization({ testOrgId: TEST_ORG_ID, enterpriseOrgId: ENTERPRISE_ORG_ID });
},
'organization-members': async () => {
console.log('📝 Seeding organization members...');
await seedOrganizationMember({ testOrgId: TEST_ORG_ID, adminUserId: ADMIN_USER_ID });
await seedOrganizationMember({
testOrgId: TEST_ORG_ID,
adminUserId: ADMIN_USER_ID,
studentUserId: STUDENT_USER_ID,
enterpriseOrgId: ENTERPRISE_ORG_ID,
enterpriseAdminUserId: ENTERPRISE_ADMIN_USER_ID,
enterpriseStudentUserId: ENTERPRISE_STUDENT_USER_ID
});
},
'organization-plan': async () => {
console.log('📝 Seeding enterprise organization plan...');
await seedEnterpriseOrganizationPlan({ enterpriseOrgId: ENTERPRISE_ORG_ID });
},
groups: async () => {
console.log('📝 Seeding groups...');
@@ -205,6 +221,7 @@ async function seed() {
await seedFunctions.accounts();
await seedFunctions.organizations();
await seedFunctions['organization-members']();
await seedFunctions['organization-plan']();
await seedFunctions.groups();
await seedFunctions['group-members']();
await seedFunctions.courses();
@@ -225,6 +242,7 @@ async function seed() {
'accounts',
'organizations',
'organization-members',
'organization-plan',
'groups',
'group-members',
'courses',
+12 -3
View File
@@ -39,7 +39,10 @@ export async function seedCourses({ mvcGroupId, reactGroupId, pandasGroupId }: S
cost: 0,
currency: 'NGN',
isPublished: true,
isCertificateDownloadable: false,
certificate: {
isDownloadable: false,
theme: 'professional'
},
status: 'ACTIVE'
},
{
@@ -63,7 +66,10 @@ export async function seedCourses({ mvcGroupId, reactGroupId, pandasGroupId }: S
cost: 0,
currency: 'NGN',
isPublished: true,
isCertificateDownloadable: false,
certificate: {
isDownloadable: false,
theme: 'professional'
},
status: 'ACTIVE'
},
{
@@ -87,7 +93,10 @@ export async function seedCourses({ mvcGroupId, reactGroupId, pandasGroupId }: S
cost: 0,
currency: 'NGN',
isPublished: true,
isCertificateDownloadable: false,
certificate: {
isDownloadable: false,
theme: 'professional'
},
status: 'ACTIVE'
}
].filter((c) => !existingCourseIds.includes(c.id));
+14 -1
View File
@@ -1,6 +1,6 @@
import { db, organization } from '@db/drizzle';
export async function seedOrganization({ testOrgId }: { testOrgId: string }) {
export async function seedOrganization({ testOrgId, enterpriseOrgId }: { testOrgId: string; enterpriseOrgId: string }) {
const existingOrgs = await db.select().from(organization);
const existingOrgIds = existingOrgs.map((o) => o.id);
@@ -17,6 +17,19 @@ export async function seedOrganization({ testOrgId }: { testOrgId: string }) {
course: { grading: true, newsfeed: true },
dashboard: { exercise: true, community: true, bannerText: '', bannerImage: '' }
}
},
{
id: enterpriseOrgId,
name: 'Coursera Test',
siteName: 'coursera-test',
settings: {},
landingpage: {},
theme: '',
customization: {
apps: { poll: true, comments: true },
course: { grading: true, newsfeed: true },
dashboard: { exercise: true, community: true, bannerText: '', bannerImage: '' }
}
}
].filter((o) => !existingOrgIds.includes(o.id));
@@ -0,0 +1,28 @@
import { and, db, eq, organizationPlan } from '@db/drizzle';
export async function seedEnterpriseOrganizationPlan({ enterpriseOrgId }: { enterpriseOrgId: string }) {
const existing = await db
.select()
.from(organizationPlan)
.where(
and(
eq(organizationPlan.orgId, enterpriseOrgId),
eq(organizationPlan.planName, 'ENTERPRISE'),
eq(organizationPlan.isActive, true)
)
);
if (existing.length > 0) {
console.log(' ✓ Enterprise organization plan already exists, skipping');
return;
}
await db.insert(organizationPlan).values({
orgId: enterpriseOrgId,
planName: 'ENTERPRISE',
isActive: true,
subscriptionId: 'seed-coursera-test-enterprise',
provider: 'seed'
});
console.log(' ✓ Inserted enterprise organization plan');
}
@@ -3,14 +3,23 @@ import { db, organizationmember } from '@db/drizzle';
interface SeedOrganizationMember {
testOrgId: string;
adminUserId: string;
studentUserId: string;
enterpriseOrgId: string;
enterpriseAdminUserId: string;
enterpriseStudentUserId: string;
}
export async function seedOrganizationMember({ testOrgId, adminUserId }: SeedOrganizationMember) {
export async function seedOrganizationMember({
testOrgId,
adminUserId,
studentUserId,
enterpriseOrgId,
enterpriseAdminUserId,
enterpriseStudentUserId
}: SeedOrganizationMember) {
const existingOrgMembers = await db.select().from(organizationmember);
const existingOrgMemberKeys = existingOrgMembers.map((om) => `${om.organizationId}-${om.profileId}`);
const studentUserId = '0c256e75-aa40-4f62-8d30-0217ca1c60d9';
const orgMembersToInsert = [
{
organizationId: testOrgId,
@@ -23,6 +32,18 @@ export async function seedOrganizationMember({ testOrgId, adminUserId }: SeedOrg
roleId: 3, // STUDENT
profileId: studentUserId,
verified: false
},
{
organizationId: enterpriseOrgId,
roleId: 1, // ADMIN
profileId: enterpriseAdminUserId,
verified: false
},
{
organizationId: enterpriseOrgId,
roleId: 3, // STUDENT
profileId: enterpriseStudentUserId,
verified: false
}
].filter((om) => !existingOrgMemberKeys.includes(`${om.organizationId}-${om.profileId}`));
+102
View File
@@ -151,5 +151,107 @@
}
],
"profile_fullname": "Elon Gates"
},
{
"instance_id": "00000000-0000-0000-0000-000000000000",
"id": "3c9052de-7e4f-4c3a-8d8e-2d5e9b7f1a3c",
"aud": "authenticated",
"role": "authenticated",
"email": "[email protected]",
"encrypted_password": "$2a$10$n8vBI6.pyE0W/RO9DcJDseLKF/CRwsU4X4Lc2MaQogt8pQgnJavTa",
"email_confirmed_at": "2025-03-23T12:00:00.000Z",
"invited_at": null,
"confirmation_token": "",
"confirmation_sent_at": null,
"recovery_token": "",
"recovery_sent_at": null,
"email_change_token_new": "",
"email_change": "",
"email_change_sent_at": null,
"last_sign_in_at": null,
"raw_app_meta_data": { "provider": "email", "providers": ["email"] },
"raw_user_meta_data": {},
"is_super_admin": null,
"created_at": "2025-03-23T12:00:00.000Z",
"updated_at": "2025-03-23T12:00:00.000Z",
"phone": null,
"phone_confirmed_at": null,
"phone_change": "",
"phone_change_token": "",
"phone_change_sent_at": null,
"confirmed_at": "2025-03-23T12:00:00.000Z",
"email_change_token_current": "",
"email_change_confirm_status": 0,
"banned_until": null,
"reauthentication_token": "",
"reauthentication_sent_at": null,
"is_sso_user": false,
"deleted_at": null,
"is_anonymous": false,
"identities": [
{
"provider_id": "3c9052de-7e4f-4c3a-8d8e-2d5e9b7f1a3c",
"user_id": "3c9052de-7e4f-4c3a-8d8e-2d5e9b7f1a3c",
"identity_data": { "sub": "3c9052de-7e4f-4c3a-8d8e-2d5e9b7f1a3c", "email": "[email protected]" },
"provider": "email",
"last_sign_in_at": "2025-03-23T12:00:00.000Z",
"created_at": "2025-03-23T12:00:00.000Z",
"updated_at": "2025-03-23T12:00:00.000Z",
"email": "[email protected]",
"id": "5eb27480-91aa-4bcc-8e2f-33ddac914761"
}
],
"profile_fullname": "Enterprise Admin"
},
{
"instance_id": "00000000-0000-0000-0000-000000000000",
"id": "4da163ef-8050-4d4b-9c9f-3e6fac802b4d",
"aud": "authenticated",
"role": "authenticated",
"email": "[email protected]",
"encrypted_password": "$2a$10$n8vBI6.pyE0W/RO9DcJDseLKF/CRwsU4X4Lc2MaQogt8pQgnJavTa",
"email_confirmed_at": "2025-03-23T12:00:00.000Z",
"invited_at": null,
"confirmation_token": "",
"confirmation_sent_at": null,
"recovery_token": "",
"recovery_sent_at": null,
"email_change_token_new": "",
"email_change": "",
"email_change_sent_at": null,
"last_sign_in_at": null,
"raw_app_meta_data": { "provider": "email", "providers": ["email"] },
"raw_user_meta_data": {},
"is_super_admin": null,
"created_at": "2025-03-23T12:00:00.000Z",
"updated_at": "2025-03-23T12:00:00.000Z",
"phone": null,
"phone_confirmed_at": null,
"phone_change": "",
"phone_change_token": "",
"phone_change_sent_at": null,
"confirmed_at": "2025-03-23T12:00:00.000Z",
"email_change_token_current": "",
"email_change_confirm_status": 0,
"banned_until": null,
"reauthentication_token": "",
"reauthentication_sent_at": null,
"is_sso_user": false,
"deleted_at": null,
"is_anonymous": false,
"identities": [
{
"provider_id": "4da163ef-8050-4d4b-9c9f-3e6fac802b4d",
"user_id": "4da163ef-8050-4d4b-9c9f-3e6fac802b4d",
"identity_data": { "sub": "4da163ef-8050-4d4b-9c9f-3e6fac802b4d", "email": "[email protected]" },
"provider": "email",
"last_sign_in_at": "2025-03-23T12:00:00.000Z",
"created_at": "2025-03-23T12:00:00.000Z",
"updated_at": "2025-03-23T12:00:00.000Z",
"email": "[email protected]",
"id": "6fc38591-a2bb-5ccd-9f3a-44eebda025872"
}
],
"profile_fullname": "Enterprise Student"
}
]
+3
View File
@@ -13,6 +13,7 @@ export * from './on-password-reset';
export * from './invite-teacher';
export * from './newsfeed';
export * from './student-course-welcome';
export * from './student-course-completion';
export * from './student-course-invite';
export * from './teacher-student-joined';
export * from './teacher-student-buy-request';
@@ -27,6 +28,7 @@ import type { forgotPasswordEmail } from './forgot-password';
import type { inviteTeacherEmail } from './invite-teacher';
import type { onPasswordResetEmail } from './on-password-reset';
import type { studentCourseInviteEmail } from './student-course-invite';
import type { studentCourseCompletionEmail } from './student-course-completion';
import type { studentCourseWelcomeEmail } from './student-course-welcome';
import type { studentProvePaymentEmail } from './student-prove-payment';
import type { studentOrgInviteEmail } from './student-org-invite';
@@ -51,6 +53,7 @@ export type EmailSchemas = {
newsfeedPost: typeof newsfeedPostEmail.template.schema;
newsfeedComment: typeof newsfeedCommentEmail.template.schema;
studentCourseWelcome: typeof studentCourseWelcomeEmail.template.schema;
studentCourseCompletion: typeof studentCourseCompletionEmail.template.schema;
studentCourseInvite: typeof studentCourseInviteEmail.template.schema;
teacherStudentJoined: typeof teacherStudentJoinedEmail.template.schema;
teacherStudentBuyRequest: typeof teacherStudentBuyRequestEmail.template.schema;
@@ -0,0 +1,33 @@
import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
export const studentCourseCompletionEmail = defineEmail({
id: 'studentCourseCompletion',
subject: 'Congratulations — you completed the course requirements',
schema: z.object({
orgName: z.string().min(1),
courseName: z.string().min(1),
studentName: z.string().min(1),
certificateUrl: z.string().url(),
customMessage: z.string().nullable().optional()
}),
render: (fields) => {
const customBlock =
fields.customMessage && fields.customMessage.trim().length > 0
? `<div style="margin:16px 0;padding:12px;border-left:3px solid #6366f1;background:#f8fafc;">${fields.customMessage}</div>`
: '';
const content = `
<p>Hi ${fields.studentName},</p>
<p>Congratulations! You have met the completion requirements for <strong>${fields.courseName}</strong>.</p>
${customBlock}
<p><a href="${fields.certificateUrl}" style="display:inline-block;padding:10px 16px;background:#111827;color:#fff;text-decoration:none;border-radius:6px;">View your certificate</a></p>
<p>If the button does not work, copy and paste this link into your browser:<br/><span style="word-break:break-all;">${fields.certificateUrl}</span></p>
<p>Cheers,<br/>${fields.orgName}</p>
`;
return getDefaultTemplate(content);
}
});
@@ -5,17 +5,18 @@ import { getDefaultTemplate } from '../templates';
export const studentCourseWelcomeEmail = defineEmail({
id: 'studentCourseWelcome',
subject: 'Welcome to Class 🎉',
subject: 'You have access to a course — sign in to get started',
schema: z.object({
orgName: z.string().min(1),
courseName: z.string().min(1)
courseName: z.string().min(1),
loginUrl: z.string().min(1)
}),
render: (fields) => {
const content = `
<p>Hi there,</p>
<p>Congratulations 🎉, you've successfully joined: <strong>${fields.courseName}</strong></p>
<p>Everything has been set up for you to have an amazing learning experience.</p>
<p>If you run into any issues, don't fail to reach out to your instructor(s).</p>
<p>You now have access to <strong>${fields.courseName}</strong> in <strong>${fields.orgName}</strong>.</p>
<p><a href="${fields.loginUrl}">Sign in to ClassroomIO</a> to open the course and get started.</p>
<p>If you run into any issues, reach out to your instructor(s).</p>
<p>Cheers,</p>
<p>${fields.orgName}</p>
`;
+1
View File
@@ -7,6 +7,7 @@ export const EMAIL_IDS = [
'newsfeedPost',
'onPasswordReset',
'studentCourseInvite',
'studentCourseCompletion',
'studentCourseWelcome',
'studentOrgInvite',
'studentProvePayment',
@@ -0,0 +1,349 @@
import type { AnswerData } from './answer-data';
import type { ExerciseQuestionModel, ExerciseQuestionOption } from './exercise-types';
import { QUESTION_TYPE_KEY } from './question-type-keys';
import { getQuestionTypeById } from './question-type-lookup';
import { QUESTION_TYPE_ID_TO_KEY } from './question-type-registry';
import { supportsPartialCredit } from './question-type-capabilities';
export type DbQuestionForScoring = {
id: number;
title?: string | null;
questionTypeId: number;
points: number | null;
settings?: Record<string, unknown> | null;
options: Array<{
id?: number | null;
label?: string | null;
value?: string | null;
isCorrect?: boolean | null;
}>;
};
function normalizeText(value: unknown): string {
return String(value ?? '')
.trim()
.toLowerCase();
}
export function dbQuestionToExerciseQuestionModel(q: DbQuestionForScoring): ExerciseQuestionModel {
const key = QUESTION_TYPE_ID_TO_KEY[q.questionTypeId] ?? QUESTION_TYPE_KEY.RADIO;
const options: ExerciseQuestionOption[] = (q.options ?? [])
.filter((o) => o.id != null)
.map((o) => ({
id: o.id as number,
label: String(o.label ?? ''),
value: o.value ?? undefined,
isCorrect: o.isCorrect ?? false
}));
const rawSettings = q.settings;
const settings =
rawSettings && typeof rawSettings === 'object' && !Array.isArray(rawSettings) ? { ...rawSettings } : {};
return {
id: q.id,
title: q.title ?? '',
questionType: key,
points: Number(q.points ?? 0),
options,
settings
};
}
/** True when every question type id refers to an auto-gradable type (from registry). */
export function isExerciseAutoGradable(questionTypeIds: number[]): boolean {
if (questionTypeIds.length === 0) return false;
return questionTypeIds.every((id) => {
const meta = getQuestionTypeById(id);
return meta?.autoGradable === true;
});
}
function clampPoints(raw: number, max: number): number {
if (!Number.isFinite(raw) || raw < 0) return 0;
return Math.min(Math.round(raw * 100) / 100, max);
}
function scoreRadio(
question: ExerciseQuestionModel,
answer: Extract<AnswerData, { type: 'RADIO' }>,
maxPoints: number
): number {
const selected = question.options?.find((o) => Number(o.id) === Number(answer.optionId));
if (!selected?.isCorrect) return 0;
return maxPoints;
}
function scoreCheckbox(
question: ExerciseQuestionModel,
answer: Extract<AnswerData, { type: 'CHECKBOX' }>,
maxPoints: number,
partial: boolean
): number {
const options = question.options ?? [];
const correctIds = new Set(
options
.filter((o) => o.isCorrect)
.map((o) => Number(o.id))
.filter((id) => Number.isFinite(id))
);
const selected = new Set(answer.optionIds.map((id) => Number(id)));
if (correctIds.size === 0) return 0;
let correctSelected = 0;
let wrongSelected = 0;
for (const id of selected) {
if (correctIds.has(id)) correctSelected++;
else wrongSelected++;
}
if (!partial) {
return correctSelected === correctIds.size && wrongSelected === 0 ? maxPoints : 0;
}
const incorrectPool = Math.max(1, options.length - correctIds.size);
const ratio = correctSelected / correctIds.size - wrongSelected / incorrectPool;
return clampPoints(Math.max(0, ratio) * maxPoints, maxPoints);
}
function scoreTrueFalse(
question: ExerciseQuestionModel,
answer: Extract<AnswerData, { type: 'TRUE_FALSE' }>,
maxPoints: number
): number {
const opts = question.options ?? [];
const trueOpt = opts.find((o) => normalizeText(o.label) === 'true');
const falseOpt = opts.find((o) => normalizeText(o.label) === 'false');
const correctFromSettings = question.settings?.correctValue;
let correctIsTrue: boolean;
if (typeof correctFromSettings === 'boolean') {
correctIsTrue = correctFromSettings;
} else if (trueOpt?.isCorrect) {
correctIsTrue = true;
} else if (falseOpt?.isCorrect) {
correctIsTrue = false;
} else {
correctIsTrue = true;
}
return answer.value === correctIsTrue ? maxPoints : 0;
}
function scoreNumeric(
question: ExerciseQuestionModel,
answer: Extract<AnswerData, { type: 'NUMERIC' }>,
maxPoints: number
): number {
const rawCorrect = question.settings?.correctValue;
const correct = typeof rawCorrect === 'number' ? rawCorrect : rawCorrect != null ? Number(rawCorrect) : NaN;
if (Number.isNaN(correct)) return 0;
const rawTol = question.settings?.tolerance;
const tol = typeof rawTol === 'number' && Number.isFinite(rawTol) ? rawTol : rawTol != null ? Number(rawTol) : 0;
const diff = Math.abs(answer.value - correct);
return diff <= tol ? maxPoints : 0;
}
function scoreFillBlank(
question: ExerciseQuestionModel,
answer: Extract<AnswerData, { type: 'FILL_BLANK' }>,
maxPoints: number,
partial: boolean
): number {
const raw = String(question.settings?.acceptedAnswers ?? '');
const expected = raw
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
const studentVals = answer.values.map((v) => String(v).trim().toLowerCase());
if (expected.length === 0) return 0;
let correct = 0;
for (let i = 0; i < expected.length; i++) {
if (studentVals[i] === expected[i]) correct++;
}
if (!partial) {
return correct === expected.length && studentVals.length === expected.length ? maxPoints : 0;
}
return (correct / expected.length) * maxPoints;
}
function pairKey(left: string, right: string): string {
return `${normalizeText(left)}::${normalizeText(right)}`;
}
function scoreMatching(
question: ExerciseQuestionModel,
answer: Extract<AnswerData, { type: 'MATCHING' }>,
maxPoints: number,
partial: boolean
): number {
const rawPairs = question.settings?.pairs;
const correctPairs = Array.isArray(rawPairs) ? (rawPairs as Array<{ left: string; right: string }>) : [];
if (correctPairs.length === 0) return 0;
const correctSet = new Set(correctPairs.map((p) => pairKey(p.left, p.right)));
const studentPairs = answer.pairs ?? [];
let matches = 0;
for (const p of studentPairs) {
if (correctSet.has(pairKey(p.left, p.right))) matches++;
}
if (!partial) {
return matches === correctSet.size && studentPairs.length === correctSet.size ? maxPoints : 0;
}
return (matches / correctSet.size) * maxPoints;
}
type OrderingItem = { label: string; answerValue: string };
function getOrderingItems(question: ExerciseQuestionModel): OrderingItem[] {
const opts = question.options ?? [];
if (opts.length > 0) {
return opts.map((o, index) => {
const label = String(o.label ?? o.value ?? '').trim() || String(index + 1);
const id = String(o.id ?? o.value ?? o.label ?? `ordering-${index + 1}`);
const answerValue = String(o.id ?? o.value ?? o.label ?? id);
return { label, answerValue };
});
}
const rawItems = Array.isArray(question.settings?.items) ? (question.settings.items as string[]) : [];
return rawItems.map((label, index) => ({
label: String(label).trim() || String(index + 1),
answerValue: String(label).trim()
}));
}
function getExpectedOrderingValues(question: ExerciseQuestionModel): string[] {
const items = getOrderingItems(question);
const settingsItems = Array.isArray(question.settings?.items) ? (question.settings!.items as string[]) : [];
if (settingsItems.length > 0) {
const byLabel = new Map(items.map((i) => [normalizeText(i.label), i.answerValue]));
return settingsItems.map((l) => byLabel.get(normalizeText(l)) ?? String(l));
}
return items.map((i) => i.answerValue);
}
function scoreOrdering(
question: ExerciseQuestionModel,
answer: Extract<AnswerData, { type: 'ORDERING' }>,
maxPoints: number,
partial: boolean
): number {
const expected = getExpectedOrderingValues(question);
const student = (answer.orderedValues ?? []).map((v) => String(v).trim());
if (expected.length === 0) return 0;
if (student.length !== expected.length) {
return partial ? 0 : 0;
}
let correct = 0;
for (let i = 0; i < expected.length; i++) {
if (normalizeText(student[i]) === normalizeText(expected[i])) correct++;
}
if (!partial) {
return correct === expected.length ? maxPoints : 0;
}
return (correct / expected.length) * maxPoints;
}
function distance(a: { x: number; y: number }, b: { x: number; y: number }): number {
return Math.hypot(a.x - b.x, a.y - b.y);
}
const HOTSPOT_TOLERANCE = 12;
function scoreHotspot(
question: ExerciseQuestionModel,
answer: Extract<AnswerData, { type: 'HOTSPOT' }>,
maxPoints: number,
partial: boolean
): number {
const raw = question.settings?.hotspots;
const hotspots = Array.isArray(raw) ? (raw as Array<{ x: number; y: number }>) : [];
if (hotspots.length === 0) return 0;
const coords = answer.coordinates ?? [];
let hits = 0;
for (const h of hotspots) {
if (coords.some((c) => distance(c, h) <= HOTSPOT_TOLERANCE)) hits++;
}
if (!partial) {
return hits === hotspots.length && coords.length >= hotspots.length ? maxPoints : 0;
}
return (hits / hotspots.length) * maxPoints;
}
export function scoreAnswerForQuestion(question: ExerciseQuestionModel, answer: AnswerData | null | undefined): number {
const maxPoints = Number(question.points ?? 0);
if (maxPoints <= 0) return 0;
if (!answer || typeof answer !== 'object' || !('type' in answer)) return 0;
const key = question.questionType;
const partial = supportsPartialCredit(key);
switch (answer.type) {
case 'RADIO':
if (key !== QUESTION_TYPE_KEY.RADIO) return 0;
return scoreRadio(question, answer, maxPoints);
case 'CHECKBOX':
if (key !== QUESTION_TYPE_KEY.CHECKBOX) return 0;
return clampPoints(scoreCheckbox(question, answer, maxPoints, partial), maxPoints);
case 'TRUE_FALSE':
if (key !== QUESTION_TYPE_KEY.TRUE_FALSE) return 0;
return scoreTrueFalse(question, answer, maxPoints);
case 'NUMERIC':
if (key !== QUESTION_TYPE_KEY.NUMERIC) return 0;
return scoreNumeric(question, answer, maxPoints);
case 'FILL_BLANK':
if (key !== QUESTION_TYPE_KEY.FILL_BLANK) return 0;
return clampPoints(scoreFillBlank(question, answer, maxPoints, partial), maxPoints);
case 'MATCHING':
if (key !== QUESTION_TYPE_KEY.MATCHING) return 0;
return clampPoints(scoreMatching(question, answer, maxPoints, partial), maxPoints);
case 'ORDERING':
if (key !== QUESTION_TYPE_KEY.ORDERING) return 0;
return clampPoints(scoreOrdering(question, answer, maxPoints, partial), maxPoints);
case 'HOTSPOT':
if (key !== QUESTION_TYPE_KEY.HOTSPOT) return 0;
return clampPoints(scoreHotspot(question, answer, maxPoints, partial), maxPoints);
default:
return 0;
}
}
export type ScoreSubmissionResult = {
scores: Array<{ questionId: number; points: number }>;
total: number;
};
/**
* Computes per-question scores and total for auto-graded exercises.
* Missing answers score 0.
*/
export function scoreSubmissionAnswers(
questions: DbQuestionForScoring[],
answersByQuestionId: ReadonlyMap<number, AnswerData | null | undefined>
): ScoreSubmissionResult {
const scores: Array<{ questionId: number; points: number }> = [];
let total = 0;
for (const q of questions) {
const model = dbQuestionToExerciseQuestionModel(q);
const meta = getQuestionTypeById(q.questionTypeId);
if (!meta?.autoGradable) {
scores.push({ questionId: q.id, points: 0 });
continue;
}
const answer = answersByQuestionId.get(q.id);
const pts = Math.round(scoreAnswerForQuestion(model, answer ?? null));
scores.push({ questionId: q.id, points: pts });
total += pts;
}
return { scores, total };
}
+1
View File
@@ -44,6 +44,7 @@
},
"dependencies": {
"@cio/question-types": "workspace:*",
"@cio/utils": "workspace:*",
"@floating-ui/dom": "^1.7.4",
"@tiptap/core": "^3.10.7",
"@tiptap/extension-bubble-menu": "^3.10.7",
@@ -71,6 +71,6 @@
</Button>
</div>
<pre bind:this={preRef} draggable={false}>
<NodeViewContent as="code" class={`language-${defaultLanguage}`} {...node.attrs} />
<NodeViewContent as="code" class={`language-${defaultLanguage} ui:pt-3!`} {...node.attrs} />
</pre>
</NodeViewWrapper>
@@ -1,4 +1,6 @@
<script lang="ts">
import CheckIcon from '@lucide/svelte/icons/check';
import XIcon from '@lucide/svelte/icons/x';
import { getExerciseQuestionLabel, type ExerciseQuestionLabels } from '@cio/question-types';
import { Badge } from '../../../../base/badge';
@@ -10,13 +12,17 @@
let { labels, showCorrect = false, showIncorrect = false }: Props = $props();
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
getExerciseQuestionLabel(labels, key, fallback);
const ariaCorrectLabel = $derived(getExerciseQuestionLabel(labels, 'common.correct_badge', 'Correct'));
const ariaIncorrectLabel = $derived(getExerciseQuestionLabel(labels, 'common.incorrect_badge', 'Incorrect'));
</script>
{#if showCorrect}
<Badge variant="success" class="ui:ml-2">{label('common.correct_badge')}</Badge>
<Badge variant="success" class="ui:ml-2 ui:p-1" aria-label={ariaCorrectLabel} title={ariaCorrectLabel}>
<CheckIcon aria-hidden="true" class="custom" />
</Badge>
{/if}
{#if showIncorrect}
<Badge variant="destructive" class="ui:ml-2">{label('common.incorrect_badge')}</Badge>
<Badge variant="destructive" class="ui:ml-2 ui:p-1" aria-label={ariaIncorrectLabel} title={ariaIncorrectLabel}>
<XIcon aria-hidden="true" class="custom" />
</Badge>
{/if}
@@ -1,12 +1,18 @@
<script lang="ts">
import { Spinner } from '../../base/spinner';
import { CheckboxField } from '../checkbox-field';
import { Search } from '../search';
import { cn } from '../../tools';
import type { MultiSelectListItem } from './types';
import type { Snippet } from 'svelte';
interface Props {
heading: string;
/** When omitted, the list renders without a heading row (e.g. when the parent supplies the title). */
heading?: string;
headingSnippet?: Snippet;
emptyMessage: string;
items: MultiSelectListItem[];
isLoading?: boolean;
isSelected: (id: string) => boolean;
onToggle: (id: string) => void;
/** Prefix for each row's checkbox `name` (e.g. `assign-course` → `assign-course-<id>`). */
@@ -14,35 +20,78 @@
class?: string;
/** Extra classes for the scrollable list container (`data-slot="multi-select-list-items"`). */
listClass?: string;
searchPlaceholder?: string;
searchValue?: string;
onSearchValueChange?: (value: string) => void;
}
let {
heading,
heading = '',
headingSnippet,
emptyMessage,
items,
isLoading = false,
isSelected,
onToggle,
namePrefix = 'multi-select',
class: className,
listClass
listClass,
searchPlaceholder = '',
searchValue = $bindable(''),
onSearchValueChange
}: Props = $props();
const hasHeading = $derived(heading || headingSnippet);
</script>
<div data-slot="multi-select-list" class={cn('ui:border-border space-y-3 rounded-md border', className)}>
<p class="ui:border-border border-b px-2 py-2 text-sm font-medium">
{heading} ({items.length})
</p>
{#if items.length === 0}
<p class="ui:text-muted-foreground px-2 pb-2 text-sm">{emptyMessage}</p>
<div data-slot="multi-select-list" class={cn('ui:border-border ui:space-y-3 ui:rounded-md ui:border', className)}>
{#if hasHeading || searchPlaceholder}
<div
class={cn(
'ui:border-border ui:flex ui:gap-3 ui:border-b ui:px-2 ui:py-2 ui:items-center ui:justify-between',
!hasHeading && 'ui:justify-end'
)}
>
{#if heading}
<p class="ui:text-sm ui:font-medium">
{heading} ({items.length})
</p>
{:else if headingSnippet}
{@render headingSnippet()}
{/if}
{#if searchPlaceholder}
<Search
placeholder={searchPlaceholder}
bind:value={searchValue}
onValueChange={onSearchValueChange}
class="ui:w-full ui:max-w-sm"
/>
{/if}
</div>
{/if}
{#if isLoading}
<div class="ui:flex ui:min-h-40 ui:items-center ui:justify-center ui:px-2 ui:pb-2">
<Spinner class="ui:size-5 ui:text-muted-foreground" />
</div>
{:else if items.length === 0}
<p class="ui:text-muted-foreground ui:px-2 ui:pb-2 ui:text-sm">{emptyMessage}</p>
{:else}
<div data-slot="multi-select-list-items" class={cn('max-h-60 space-y-2 overflow-y-auto px-2 pb-2', listClass)}>
<div
data-slot="multi-select-list-items"
class={cn('ui:max-h-60 ui:space-y-2 ui:overflow-y-auto ui:px-2 ui:pb-2', listClass)}
>
{#each items as item (item.id)}
<CheckboxField
name={`${namePrefix}-${item.id}`}
label={item.label || item.id}
checked={isSelected(item.id)}
onclick={() => onToggle(item.id)}
/>
>
{#if item.description}
<p class="ui:ml-2 ui:text-muted-foreground ui:text-sm">{item.description}</p>
{/if}
</CheckboxField>
{/each}
</div>
{/if}
@@ -1 +1 @@
export type MultiSelectListItem = { id: string; label: string };
export type MultiSelectListItem = { id: string; label: string; description?: string };
+20 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import * as InputGroup from '../../base/input-group';
import SearchIcon from '@lucide/svelte/icons/search';
import XIcon from '@lucide/svelte/icons/x';
import type { ComponentProps } from 'svelte';
import { cn } from '../../tools';
@@ -8,12 +9,14 @@
placeholder?: string;
value?: string;
class?: string;
onValueChange?: (value: string) => void;
}
let {
placeholder = '',
value = $bindable(''),
class: className = '',
onValueChange,
...restProps
}: Props & Omit<ComponentProps<typeof InputGroup.Root>, 'class' | 'children'> = $props();
@@ -22,10 +25,26 @@
'data-slot': 'search',
...restProps
});
function handleInput(event: Event) {
onValueChange?.((event.currentTarget as HTMLInputElement).value);
}
function clearValue() {
value = '';
onValueChange?.('');
}
</script>
<InputGroup.Root {...mergedProps}>
<InputGroup.Input {placeholder} bind:value />
<InputGroup.Input {placeholder} bind:value oninput={handleInput} />
{#if value}
<InputGroup.Addon align="inline-end">
<InputGroup.Button aria-label="Clear search" onclick={clearValue}>
<XIcon />
</InputGroup.Button>
</InputGroup.Addon>
{/if}
<InputGroup.Addon>
<SearchIcon />
</InputGroup.Addon>
File diff suppressed because one or more lines are too long
+4 -83
View File
@@ -1,6 +1,7 @@
/**
* HTML Sanitization utilities to prevent XSS attacks
*/
import { createSanitizeHtmlConfig, FORBID_TAGS, stripSvgDataUrls } from '@cio/utils/functions';
import DOMPurify from 'dompurify';
const browser = typeof window !== 'undefined' && typeof window.document !== 'undefined';
@@ -17,77 +18,10 @@ export function sanitizeHtml(html: string): string {
return fallbackSanitize(html);
}
let sanitized = DOMPurify.sanitize(html, {
// Allow safe HTML tags
ALLOWED_TAGS: [
'p',
'br',
'strong',
'b',
'em',
'i',
'u',
'span',
'div',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'ul',
'ol',
'li',
'blockquote',
'pre',
'code',
'a',
'img'
],
// Allow safe attributes
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'style', 'data-type', 'data-latex'],
// Forbid dangerous attributes
FORBID_ATTR: [
'onerror',
'onload',
'onclick',
'onmouseover',
'onfocus',
'onblur',
'onchange',
'onsubmit',
'onreset',
'onselect',
'onunload'
],
// Forbid dangerous tags (including svg)
FORBID_TAGS: [
'script',
'iframe',
'object',
'embed',
'form',
'input',
'textarea',
'select',
'button',
'meta',
'link',
'svg',
'math'
],
// Keep content of forbidden tags
KEEP_CONTENT: true,
// Sanitize attributes
SANITIZE_DOM: true,
// Allow data attributes
ALLOW_DATA_ATTR: false,
// Transform URLs and filter dangerous protocols
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i
});
let sanitized = String(DOMPurify.sanitize(html, createSanitizeHtmlConfig()));
// Additional post-processing to remove any SVG data URLs that might have slipped through
sanitized = sanitized.replace(/src\s*=\s*["']data:image\/svg[^"']*["']/gi, 'src=""');
sanitized = stripSvgDataUrls(sanitized);
return sanitized;
}
@@ -125,20 +59,7 @@ function fallbackSanitize(html: string): string {
html = html.replace(/expression\s*\(/gi, '');
// Remove dangerous tags
const dangerousTags = [
'script',
'iframe',
'object',
'embed',
'form',
'input',
'textarea',
'select',
'button',
'svg',
'math'
];
dangerousTags.forEach((tag) => {
FORBID_TAGS.forEach((tag: string) => {
const regex = new RegExp(`<${tag}\\b[^>]*>.*?<\\/${tag}>`, 'gi');
html = html.replace(regex, '');
const selfClosing = new RegExp(`<${tag}\\b[^>]*\\/>`, 'gi');
+3 -1
View File
@@ -5,7 +5,9 @@
"baseUrl": ".",
"paths": {
"$src": ["./src"],
"$src/*": ["./src/*"]
"$src/*": ["./src/*"],
"@cio/utils": ["./node_modules/@cio/utils/dist/index.ts"],
"@cio/utils/*": ["./node_modules/@cio/utils/dist/*"]
},
"allowJs": true,
"checkJs": true,
+1
View File
@@ -1,3 +1,4 @@
export * from './array';
export * from './fileValidation';
export * from './sanitize';
export * from './slug';
+50
View File
@@ -0,0 +1,50 @@
export const FORBID_TAGS = [
'script',
'iframe',
'object',
'embed',
'form',
'input',
'textarea',
'select',
'button',
'meta',
'link',
'svg',
'math'
] as const;
export const FORBID_ATTR = [
'onerror',
'onload',
'onclick',
'onmouseover',
'onfocus',
'onblur',
'onchange',
'onsubmit',
'onreset',
'onselect',
'onunload'
] as const;
export const ADD_ATTR = ['data-type', 'data-latex', 'colwidth'] as const;
export const ALLOWED_URI_REGEXP =
/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i;
export function createSanitizeHtmlConfig() {
return {
FORBID_TAGS: [...FORBID_TAGS],
FORBID_ATTR: [...FORBID_ATTR],
ADD_ATTR: [...ADD_ATTR],
KEEP_CONTENT: true,
SANITIZE_DOM: true,
ALLOW_DATA_ATTR: false,
ALLOWED_URI_REGEXP
};
}
export function stripSvgDataUrls(html: string): string {
return html.replace(/src\s*=\s*["']data:image\/svg[^"']*["']/gi, 'src=""');
}
+13 -2
View File
@@ -279,6 +279,18 @@ export const ZCourseLandingPageMetadataUpdate = z.object({
});
export type TCourseLandingPageMetadataUpdate = z.infer<typeof ZCourseLandingPageMetadataUpdate>;
export const ZCertificationSettings = z.object({
isDownloadable: z.boolean().optional(),
theme: z.string().optional(),
/** ISO 8601 datetime string or null to clear */
deadline: z.union([z.string().min(1), z.null()]).optional(),
threshold: z.number().int().min(0).max(100).optional(),
requiredExerciseId: z.union([z.string().uuid(), z.null()]).optional(),
exerciseMinScorePercent: z.number().int().min(0).max(100).nullable().optional(),
emailMessage: z.string().max(5000).nullable().optional()
});
export type TCertificationSettings = z.infer<typeof ZCertificationSettings>;
export const ZCourseUpdate = z.object({
title: z.string().min(1).optional(),
description: z.string().min(1).optional(),
@@ -290,8 +302,7 @@ export const ZCourseUpdate = z.object({
metadata: ZCourseMetadata.optional(),
cost: z.number().int().min(0).optional(),
currency: z.enum(['NGN', 'USD']).optional(),
isCertificateDownloadable: z.boolean().optional(),
certificateTheme: z.string().optional(),
certificate: ZCertificationSettings.optional(),
tagIds: z.array(z.uuid()).max(100).optional()
});
export type TCourseUpdate = z.infer<typeof ZCourseUpdate>;
@@ -155,6 +155,7 @@ export const ZExerciseUpdate = z.object({
order: z.number().int().optional(),
isUnlocked: z.boolean().optional(),
dueBy: z.string().optional(), // Changed from iso.datetime() to string to match frontend format
allowMultipleAttempts: z.boolean().optional(),
questions: z.array(ZExerciseUpdateQuestion).optional()
});
export type TExerciseUpdate = z.infer<typeof ZExerciseUpdate>;
@@ -1,5 +1,20 @@
import * as z from 'zod';
export const AudienceSortBy = z.enum(['createdAt', 'name', 'email']);
export const AudienceSortOrder = z.enum(['asc', 'desc']);
export const ZGetAudienceQuery = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
search: z.string().trim().optional(),
sortBy: AudienceSortBy.default('createdAt'),
sortOrder: AudienceSortOrder.default('desc')
});
export type TAudienceSortBy = z.infer<typeof AudienceSortBy>;
export type TAudienceSortOrder = z.infer<typeof AudienceSortOrder>;
export type TGetAudienceQuery = z.infer<typeof ZGetAudienceQuery>;
export const ZImportAudienceMembers = z.object({
recipientCsv: z.string().max(25000),
courseIds: z.array(z.string().uuid()).optional(),