feat: certification rules, content sanitization, handle draft course updates and reset content, better redis logging
This commit is contained in:
@@ -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'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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')
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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}`));
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+10
-4
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
export * from './array';
|
||||
export * from './fileValidation';
|
||||
export * from './sanitize';
|
||||
export * from './slug';
|
||||
|
||||
@@ -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=""');
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user