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
+3 -2
View File
@@ -41,7 +41,6 @@
"@aws-sdk/client-s3": "^3.388.0",
"@aws-sdk/s3-request-presigner": "^3.675.0",
"@cio/db": "workspace:*",
"drizzle-orm": "^0.44.7",
"@cio/email": "workspace:*",
"@cio/question-types": "workspace:*",
"@cio/utils": "workspace:*",
@@ -52,13 +51,15 @@
"@sentry/profiling-node": "^7.110.1",
"axios": "^1.4.0",
"dotenv": "^16.3.1",
"drizzle-orm": "^0.44.7",
"hono": "^4.11.3",
"hono-openapi": "^1.0.8",
"hono-rate-limiter": "^0.4.2",
"redis": "^4.7.0",
"isomorphic-dompurify": "^3.7.1",
"katex": "^0.16.10",
"marked": "^7.0.3",
"nanoid": "^5.1.5",
"redis": "^4.7.0",
"zod": "^4.1.8",
"zod-openapi": "^5.4.1"
},
+2 -3
View File
@@ -9,7 +9,7 @@ import type { MiddlewareHandler } from 'hono';
import { RedisRateLimiter } from '@api/utils/redis/limiter';
import { userKeyGenerator } from '../utils/redis/key-generators';
import { env } from '@api/config/env';
import { redis } from '@api/utils/redis/redis';
import { logRedisUnavailableOnce, redis } from '@api/utils/redis/redis';
export interface RateLimiterOptions {
windowMs?: number;
@@ -80,8 +80,7 @@ export const createRateLimiter = (options: RateLimiterOptions = {}): MiddlewareH
await next();
} catch (error) {
console.error('Rate limiter error:', error);
// If Redis is down, allow the request but log the error
logRedisUnavailableOnce('Redis rate limiter unavailable, allowing requests without rate limiting', error);
await next();
}
};
+32
View File
@@ -26,6 +26,7 @@ import {
getCourseProgress,
updateCourse
} from '@api/services/course/course';
import { assertCertificateDownloadAllowed, evaluateCourseCertification } from '@api/services/course/completion';
import { getCourseTags, replaceCourseTags } from '@api/services/tag';
import { Hono } from '@api/utils/hono';
@@ -393,6 +394,33 @@ export const courseRouter = new Hono()
}
}
)
/**
* GET /course/:courseId/certification-evaluation
* Full certification eligibility for the current user (blockers for UI).
*/
.get(
'/:courseId/certification-evaluation',
authMiddleware,
courseMemberMiddleware,
zValidator('param', ZCourseProgressParam),
async (c) => {
try {
const { courseId } = c.req.valid('param');
const user = c.get('user')!;
const data = await evaluateCourseCertification(courseId, user.id);
return c.json(
{
success: true,
data
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to evaluate certification');
}
}
)
/**
* GET /course/:courseId/analytics
* Gets course analytics including student progress, completion rates, and grades
@@ -427,6 +455,10 @@ export const courseRouter = new Hono()
zValidator('param', ZCourseDownloadParam),
zValidator('json', ZCertificateDownload),
async (c) => {
const { courseId } = c.req.valid('param');
const user = c.get('user')!;
await assertCertificateDownloadAllowed(courseId, user.id);
const validatedData = c.req.valid('json');
const result = await generateCertificate(validatedData);
@@ -4,6 +4,7 @@ import {
ZCancelOrgPlan,
ZCreateOrgPlan,
ZCreateOrganization,
ZGetAudienceQuery,
ZGetCoursesBySiteName,
ZGetOrgSetup,
ZGetOrganizationCoursesQuery,
@@ -37,6 +38,7 @@ import {
getRecommendedCourses,
getUserAnalytics,
getUserEnrolledCourses,
removeAudienceMember,
removeTeamMember,
updateOrg,
updateOrgPlan
@@ -178,15 +180,18 @@ export const organizationRouter = new Hono()
* Gets organization audience (students)
* Requires authentication
*/
.get('/audience', authMiddleware, orgTeamMemberMiddleware, async (c) => {
.get('/audience', authMiddleware, orgTeamMemberMiddleware, zValidator('query', ZGetAudienceQuery), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const audience = await getOrgAudience(orgId);
const query = c.req.valid('query');
const audience = await getOrgAudience(orgId, query);
return c.json(
{
success: true,
data: audience
data: audience.items,
pagination: audience.pagination,
query: audience.query
},
200
);
@@ -194,6 +199,35 @@ export const organizationRouter = new Hono()
return handleError(c, error, 'Failed to fetch organization audience');
}
})
/**
* DELETE /organization/audience/:memberId
* Removes a student from the organization
*/
.delete(
'/audience/:memberId',
authMiddleware,
orgAdminMiddleware,
zValidator('param', ZRemoveTeamMember),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const user = c.get('user')!;
const { memberId } = c.req.valid('param');
const deleted = await removeAudienceMember(orgId, memberId, user.id);
return c.json(
{
success: true,
data: deleted
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to remove audience member');
}
}
)
/**
* POST /organization/audience/resend-invite
* Resends a student org invite email (new token; optional course metadata from last invite)
@@ -604,7 +638,7 @@ export const organizationRouter = new Hono()
.post(
'/audience/assign-courses',
authMiddleware,
orgAdminMiddleware,
orgTeamMemberMiddleware,
zValidator('json', ZAssignAudienceCourses),
async (c) => {
try {
@@ -2,9 +2,20 @@ import { AppError, ErrorCodes } from '@api/utils/errors';
import { env } from '@api/config/env';
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { createCourse, updateCourse } from '@api/services/course/course';
import { createExercise, getExercise, listExercises, replaceExerciseService } from '@api/services/exercise';
import { createCourseSection, listCourseSections, updateCourseSectionService } from '@api/services/course/section';
import { createLesson, listLessons, updateLessonService } from '@api/services/lesson';
import {
createExercise,
deleteExerciseService,
getExercise,
listExercises,
replaceExerciseService
} from '@api/services/exercise';
import {
createCourseSection,
deleteCourseSectionService,
listCourseSections,
updateCourseSectionService
} from '@api/services/course/section';
import { createLesson, listLessons, updateLessonService, deleteLessonService } from '@api/services/lesson';
import { upsertLessonLanguageService, updateLessonLanguageService } from '@api/services/lesson-language';
import {
createCourseImportDraft,
@@ -203,6 +214,43 @@ function parseStoredDraft(record: TCourseImportDraft): TCourseImportDraftPayload
return ZCourseImportDraftPayload.parse(record.draft);
}
async function reserveDraftPublishedCourseId(
orgId: string,
draftId: string,
draft: TCourseImportDraftPayload,
existingSummary: TCourseImportDraft['summary'],
courseId: string
) {
await updateCourseImportDraft(orgId, draftId, {
publishedCourseId: courseId,
summary: {
...(existingSummary ?? {}),
...buildDraftSummary(draft),
publishedCourseId: courseId
}
});
}
async function resetCourseContentForDraftRetry(courseId: string) {
const [exercises, lessons, sections] = await Promise.all([
listExercises(courseId),
listLessons(courseId),
listCourseSections(courseId)
]);
for (const exercise of exercises) {
await deleteExerciseService(exercise.id);
}
for (const lesson of lessons) {
await deleteLessonService(lesson.id);
}
for (const section of sections) {
await deleteCourseSectionService(section.id);
}
}
async function assertCourseBelongsToOrganization(orgId: string, courseId: string) {
const courseOrgId = await getCourseOrganizationId(courseId);
@@ -665,6 +713,14 @@ export async function publishCourseImportDraftService(
const courseDescription = overrides.description ?? draft.course.description;
const courseType = overrides.type ?? draft.course.type;
const courseMetadata = overrides.metadata ?? draft.course.metadata;
const courseUpdateFields: Partial<TCourse> = {
title: courseTitle,
description: courseDescription,
type: courseType
};
if (courseMetadata) {
courseUpdateFields.metadata = courseMetadata;
}
const sectionExternalIds = new Set(draft.sections.map((section) => section.externalId));
draft.lessons.forEach((lesson) => {
@@ -673,17 +729,31 @@ export async function publishCourseImportDraftService(
}
});
const courseResult = await createCourse(profileId, {
title: courseTitle,
description: courseDescription,
type: courseType,
organizationId: orgId
});
let courseId = existing.publishedCourseId;
const courseId = courseResult.course.id;
if (courseId) {
const [existingCourse] = await getCourseById(courseId);
if (!existingCourse || existingCourse.status === 'DELETED') {
courseId = null;
} else {
await updateCourse(courseId, courseUpdateFields);
await resetCourseContentForDraftRetry(courseId);
}
}
if (courseMetadata) {
await updateCourse(courseId, { metadata: courseMetadata });
if (!courseId) {
const courseResult = await createCourse(profileId, {
title: courseTitle,
description: courseDescription,
type: courseType,
organizationId: orgId
});
courseId = courseResult.course.id;
await reserveDraftPublishedCourseId(orgId, draftId, draft, existing.summary, courseId);
if (courseMetadata) {
await updateCourse(courseId, { metadata: courseMetadata });
}
}
const bannerImageUrl = await resolvePublishBannerImage(courseTitle, overrides);
+1 -2
View File
@@ -182,8 +182,7 @@ export async function cloneCourse(
currency: course.currency,
bannerImage: course.bannerImage,
isPublished: course.isPublished,
isCertificateDownloadable: course.isCertificateDownloadable,
certificateTheme: course.certificateTheme,
certificate: course.certificate,
status: course.status,
type: course.type
});
+275
View File
@@ -0,0 +1,275 @@
import * as schema from '@cio/db/schema';
import { AppError, ErrorCodes } from '@api/utils/errors';
import {
calcCourseProgressPercent,
isBeforeOrEqualDeadline,
scheduleCertificationCompletionWork
} from '@api/utils/course-completion';
import {
exerciseBelongsToCourse,
getExerciseTitleAndMaxPoints,
getStudentSubmissionsForExercise
} from '@cio/db/queries/course/certification-exercise';
import {
getCourseCertificationRow,
getCourseProgress as getCourseProgressQuery,
type TCourseCertificationRow
} from '@cio/db/queries/course/course';
import { ROLE } from '@cio/utils/constants';
import { db } from '@cio/db/drizzle';
import { eq } from 'drizzle-orm';
export type CertificationBlocker = {
code:
| 'CERT_PROGRESS'
| 'CERT_DEADLINE_PASSED'
| 'CERT_NO_CONTENT'
| 'CERT_FINAL_EXERCISE_NOT_SUBMITTED'
| 'CERT_FINAL_EXERCISE_PENDING_GRADE'
| 'CERT_FINAL_EXERCISE_SCORE'
| 'CERT_FINAL_EXERCISE_MISCONFIGURED';
params?: Record<string, string | number>;
};
export type CourseCompletionEvaluation = {
progressPercent: number;
certificationThreshold: number;
meetsThreshold: boolean;
eligibleForCertificate: boolean;
certificateEarnedAt: string | null;
blockers: CertificationBlocker[];
meetsFinalExerciseRule: boolean;
};
function percentFromTotal(total: number, maxPoints: number): number {
if (maxPoints <= 0) return 0;
return Math.round((total / maxPoints) * 100);
}
async function evaluateFinalExerciseRule(params: {
courseId: string;
requiredExerciseId: string | null | undefined;
minScorePercent: number | null | undefined;
groupMemberId: string | null;
}): Promise<{
meetsFinalExerciseRule: boolean;
blockers: CertificationBlocker[];
}> {
const { courseId, requiredExerciseId, minScorePercent, groupMemberId } = params;
if (!requiredExerciseId) {
return { meetsFinalExerciseRule: true, blockers: [] };
}
const belongs = await exerciseBelongsToCourse(requiredExerciseId, courseId);
if (!belongs) {
return { meetsFinalExerciseRule: true, blockers: [] };
}
const requiredPercent = minScorePercent ?? 100;
if (!groupMemberId) {
return {
meetsFinalExerciseRule: false,
blockers: [{ code: 'CERT_FINAL_EXERCISE_NOT_SUBMITTED' }]
};
}
const { title: exerciseTitle, maxPoints } = await getExerciseTitleAndMaxPoints(requiredExerciseId);
if (maxPoints <= 0) {
return {
meetsFinalExerciseRule: false,
blockers: [
{
code: 'CERT_FINAL_EXERCISE_MISCONFIGURED',
params: { exerciseTitle }
}
]
};
}
const submissions = await getStudentSubmissionsForExercise(groupMemberId, requiredExerciseId);
if (submissions.length === 0) {
return {
meetsFinalExerciseRule: false,
blockers: [
{
code: 'CERT_FINAL_EXERCISE_NOT_SUBMITTED',
params: { exerciseTitle }
}
]
};
}
const completed = submissions.filter((s) => s.gradingState === 'completed');
if (completed.length === 0) {
return {
meetsFinalExerciseRule: false,
blockers: [
{
code: 'CERT_FINAL_EXERCISE_PENDING_GRADE',
params: { exerciseTitle }
}
]
};
}
let bestPercent = 0;
for (const s of completed) {
const total = Number(s.total ?? 0);
const p = percentFromTotal(total, maxPoints);
if (p > bestPercent) bestPercent = p;
}
if (bestPercent < requiredPercent) {
return {
meetsFinalExerciseRule: false,
blockers: [
{
code: 'CERT_FINAL_EXERCISE_SCORE',
params: {
exerciseTitle,
bestPercent,
requiredPercent
}
}
]
};
}
return { meetsFinalExerciseRule: true, blockers: [] };
}
async function buildCertificationEvaluation(
courseId: string,
profileId: string,
courseRow: TCourseCertificationRow,
progressArg?: Awaited<ReturnType<typeof getCourseProgressQuery>>
): Promise<CourseCompletionEvaluation> {
const progress = progressArg ?? (await getCourseProgressQuery(courseId, profileId));
const certificate = courseRow.certificate ?? {};
const threshold = certificate.threshold ?? 100;
const progressPercent = calcCourseProgressPercent({
lessonsCompleted: progress.lessonsCompleted,
totalLessons: progress.lessonsCount,
exercisesCompleted: progress.exercisesCompleted,
exercisesCount: progress.exercisesCount
});
const now = new Date();
const meetsThreshold = progressPercent >= threshold;
const withinDeadline = isBeforeOrEqualDeadline(certificate.deadline, now);
const hasContent = progress.lessonsCount + progress.exercisesCount > 0;
const blockers: CertificationBlocker[] = [];
if (!withinDeadline) {
blockers.push({ code: 'CERT_DEADLINE_PASSED' });
}
if (!meetsThreshold) {
blockers.push({
code: 'CERT_PROGRESS',
params: { current: progressPercent, required: threshold }
});
}
if (!hasContent) {
blockers.push({ code: 'CERT_NO_CONTENT' });
}
const finalEval = await evaluateFinalExerciseRule({
courseId,
requiredExerciseId: certificate.requiredExerciseId,
minScorePercent: certificate.exerciseMinScorePercent,
groupMemberId: progress.groupMemberId
});
blockers.push(...finalEval.blockers);
const eligibleForCertificate =
progress.roleId === ROLE.STUDENT &&
!!progress.groupMemberId &&
meetsThreshold &&
hasContent &&
withinDeadline &&
finalEval.meetsFinalExerciseRule;
return {
progressPercent,
certificationThreshold: threshold,
meetsThreshold,
eligibleForCertificate,
certificateEarnedAt: progress.certificateEarnedAt,
blockers,
meetsFinalExerciseRule: finalEval.meetsFinalExerciseRule
};
}
/**
* Evaluates certification eligibility. On first eligibility, persists
* `certificateEarnedAt` and fires the completion email (non-blocking).
*/
export async function evaluateCourseCertification(
courseId: string,
profileId: string
): Promise<CourseCompletionEvaluation & { isNewCompletion: boolean }> {
const [progress, courseRow] = await Promise.all([
getCourseProgressQuery(courseId, profileId),
getCourseCertificationRow(courseId)
]);
if (!courseRow) {
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
}
const evaluation = await buildCertificationEvaluation(courseId, profileId, courseRow, progress);
let certificateEarnedAt = evaluation.certificateEarnedAt;
let isNewCompletion = false;
if (evaluation.eligibleForCertificate && !certificateEarnedAt && progress.groupMemberId) {
const earnedAt = new Date().toISOString();
certificateEarnedAt = earnedAt;
isNewCompletion = true;
scheduleCertificationCompletionWork({
courseId,
profileId,
groupMemberId: progress.groupMemberId,
courseRow,
earnedAt
});
}
return { ...evaluation, certificateEarnedAt, isNewCompletion };
}
/**
* Server-side guard for PDF certificate download (must match dashboard eligibility).
*/
export async function assertCertificateDownloadAllowed(courseId: string, profileId: string): Promise<void> {
const evaluation = await evaluateCourseCertification(courseId, profileId);
const [courseMeta] = await db
.select({
certificate: schema.course.certificate
})
.from(schema.course)
.where(eq(schema.course.id, courseId))
.limit(1);
if (!courseMeta?.certificate?.isDownloadable) {
throw new AppError('Certificate download is not enabled for this course', ErrorCodes.VALIDATION_ERROR, 403);
}
const hasEarnedPreviously = evaluation.certificateEarnedAt != null;
const canDownload = hasEarnedPreviously || evaluation.eligibleForCertificate;
if (!canDownload) {
throw new AppError('Course completion requirements not met', ErrorCodes.VALIDATION_ERROR, 403);
}
}
+62 -5
View File
@@ -1,4 +1,5 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { sanitizeHtml, sanitizeOptionalHtml, sanitizeUnknownStrings } from '@api/utils/sanitize-html';
import { addGroupMember, createGroup } from '@cio/db/queries/group';
import {
createCourseNewsfeed,
@@ -20,6 +21,7 @@ import {
import { ContentType, ROLE } from '@cio/utils/constants';
import type { TCourse } from '@cio/db/types';
import { db } from '@cio/db/drizzle';
import { exerciseBelongsToCourse } from '@cio/db/queries/course/certification-exercise';
import { updateExercisesSectionId } from '@cio/db/queries/exercise/exercise';
import { getProfileById } from '@cio/db/queries/auth';
import { buildCourseContent, calcPercentageWithRounding, formatLastSeen, type CourseContent } from './utils';
@@ -27,6 +29,42 @@ import { buildCourseContent, calcPercentageWithRounding, formatLastSeen, type Co
const DEFAULT_CONTENT_GROUPING = true;
const DEFAULT_SECTION_TITLE = 'First Section [edit me]';
function sanitizeCourseMetadata(metadata: TCourse['metadata'] | undefined) {
if (!metadata) return metadata;
return {
...metadata,
description: sanitizeOptionalHtml(metadata.description),
goals: sanitizeOptionalHtml(metadata.goals),
requirements: sanitizeOptionalHtml(metadata.requirements),
reward: metadata.reward
? {
...metadata.reward,
description: sanitizeHtml(metadata.reward.description)
}
: metadata.reward,
instructor: metadata.instructor
? {
...metadata.instructor,
description: sanitizeHtml(metadata.instructor.description)
}
: metadata.instructor,
reviews: metadata.reviews?.map((review) => ({
...review,
description: sanitizeHtml(review.description)
}))
};
}
function sanitizeCourseCertificate(certificate: TCourse['certificate'] | undefined) {
if (!certificate) return certificate;
return {
...certificate,
emailMessage: sanitizeOptionalHtml(certificate.emailMessage)
};
}
/**
* Gets a course by ID or slug with all related data
* @param courseId Course ID (optional if slug provided)
@@ -93,11 +131,13 @@ export async function createCourse(
data: { title: string; description: string; type: 'LIVE_CLASS' | 'SELF_PACED'; organizationId: string }
): Promise<{ course: TCourse; groupId: string; memberId: string }> {
try {
const description = sanitizeHtml(data.description);
const result = await db.transaction(async () => {
// 1. Create group
const [newGroup] = await createGroup({
name: data.title,
description: data.description,
description,
organizationId: data.organizationId
});
@@ -108,7 +148,7 @@ export async function createCourse(
// 2. Create course with group_id
const [newCourse] = await createCourseQuery({
title: data.title,
description: data.description,
description,
type: data.type,
groupId: newGroup.id
});
@@ -130,8 +170,8 @@ export async function createCourse(
// 4. Add default news feed
await createCourseNewsfeed({
content: `<h2>Welcome to this course 🎉&nbsp;</h2>
<p>Thank you for joining this course and I hope you get the best out of it.</p>`,
content: sanitizeHtml(`<h2>Welcome to this course 🎉&nbsp;</h2>
<p>Thank you for joining this course and I hope you get the best out of it.</p>`),
courseId: newCourse.id,
isPinned: true,
authorId: newMember.id
@@ -166,7 +206,24 @@ export async function createCourse(
*/
export async function updateCourse(courseId: string, data: Partial<TCourse>) {
try {
const updated = await updateCourseQuery(courseId, data);
const sanitizedData: Partial<TCourse> = {
...data,
description: sanitizeOptionalHtml(data.description),
overview: sanitizeOptionalHtml(data.overview),
metadata: sanitizeCourseMetadata(data.metadata),
certificate: sanitizeCourseCertificate(data.certificate),
logo: data.logo,
slug: data.slug
};
if (data.certificate?.requiredExerciseId) {
const ok = await exerciseBelongsToCourse(data.certificate.requiredExerciseId, courseId);
if (!ok) {
throw new AppError('Certification exercise must belong to this course', ErrorCodes.VALIDATION_ERROR, 400);
}
}
const updated = await updateCourseQuery(courseId, sanitizeUnknownStrings(sanitizedData));
if (!updated) {
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
}
+10 -3
View File
@@ -343,15 +343,18 @@ async function sendStudentJoinEmails(input: {
courseId: string;
courseName: string;
orgName: string;
orgSiteName: string | null;
studentId: string;
studentEmail: string;
}) {
try {
const loginUrl = getDashboardBaseUrl(input.orgSiteName ?? undefined);
await sendEmail('studentCourseWelcome', {
to: input.studentEmail,
fields: {
orgName: input.orgName,
courseName: input.courseName
courseName: input.courseName,
loginUrl
},
from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`)
});
@@ -678,6 +681,7 @@ export async function enrollInCourse(
courseId,
courseName: title,
orgName: org.name,
orgSiteName: org.siteName,
studentId: user.id,
studentEmail: normalizedEmail
});
@@ -962,7 +966,8 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
alreadyJoined: true,
courseId: course.id,
courseName: course.title,
orgName: organization.name
orgName: organization.name,
orgSiteName: organization.siteName
};
}
@@ -1061,7 +1066,8 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
alreadyJoined: false,
courseId: course.id,
courseName: course.title,
orgName: organization.name
orgName: organization.name,
orgSiteName: organization.siteName
};
});
@@ -1070,6 +1076,7 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
courseId: result.courseId,
courseName: result.courseName,
orgName: result.orgName,
orgSiteName: result.orgSiteName,
studentId: user.id,
studentEmail: normalizedEmail
});
+3 -1
View File
@@ -62,6 +62,7 @@ export async function addMember(
if (courseOrgData) {
const courseName = courseOrgData.courseTitle || '';
const orgName = courseOrgData.orgName || 'ClassroomIO';
const loginUrl = getDashboardBaseUrl(courseOrgData.orgSiteName ?? undefined);
// Get student profile data
let studentEmail: string | null = null;
@@ -86,7 +87,8 @@ export async function addMember(
to: studentEmail,
fields: {
orgName,
courseName
courseName,
loginUrl
},
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`)
});
+51 -19
View File
@@ -1,4 +1,5 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { sanitizeHtml, sanitizeOptionalHtml, sanitizeUnknownStrings } from '@api/utils/sanitize-html';
import type { TExercise, TExerciseTemplate, TNewOption, TNewQuestion } from '@cio/db/types';
import type { TExerciseCreate, TExerciseUpdate } from '@cio/utils/validation/exercise';
import {
@@ -40,6 +41,29 @@ type ExerciseWithCompletion = TExercise & {
isComplete: boolean;
};
function sanitizeExerciseQuestions(questions: TExerciseCreate['questions'] | TExerciseUpdate['questions']) {
return questions?.map((question) => ({
...question,
question: sanitizeHtml(question.question),
settings: question.settings ? sanitizeUnknownStrings(question.settings) : question.settings,
options: question.options?.map((option) => ({
...option,
label: sanitizeHtml(option.label),
settings: option.settings ? sanitizeUnknownStrings(option.settings) : option.settings
}))
}));
}
function sanitizeExercisePayload(data: TExerciseCreate): TExerciseCreate;
function sanitizeExercisePayload(data: TExerciseUpdate): TExerciseUpdate;
function sanitizeExercisePayload(data: TExerciseCreate | TExerciseUpdate): TExerciseCreate | TExerciseUpdate {
return {
...data,
description: sanitizeOptionalHtml(data.description),
questions: sanitizeExerciseQuestions(data.questions)
};
}
/**
* Creates a new exercise with optional questions and options
* @param data Exercise creation data
@@ -47,14 +71,16 @@ type ExerciseWithCompletion = TExercise & {
*/
export async function createExercise(data: TExerciseCreate): Promise<TExercise> {
try {
const sanitizedData = sanitizeExercisePayload(data);
const exerciseData = {
title: data.title,
description: data.description ?? null,
lessonId: data.lessonId || null,
courseId: data.courseId,
sectionId: data.sectionId || null,
order: data.order ?? null,
dueBy: data.dueBy || null
title: sanitizedData.title,
description: sanitizedData.description ?? null,
lessonId: sanitizedData.lessonId || null,
courseId: sanitizedData.courseId,
sectionId: sanitizedData.sectionId || null,
order: sanitizedData.order ?? null,
dueBy: sanitizedData.dueBy || null
};
const [exercise] = await createExercises([exerciseData]);
@@ -63,10 +89,10 @@ export async function createExercise(data: TExerciseCreate): Promise<TExercise>
}
// Create questions and options if provided
if (data.questions && data.questions.length > 0) {
if (sanitizedData.questions && sanitizedData.questions.length > 0) {
await db.transaction(async (tx) => {
const txClient = tx as DbOrTxClient;
const questionsData: TNewQuestion[] = data.questions!.map((q) => ({
const questionsData: TNewQuestion[] = sanitizedData.questions!.map((q) => ({
exerciseId: exercise.id,
title: q.question,
questionTypeId: q.questionTypeId || 1,
@@ -78,7 +104,7 @@ export async function createExercise(data: TExerciseCreate): Promise<TExercise>
const questions = await createQuestions(questionsData, txClient);
// Create options for each question
const optionsPromises = data.questions!.map(async (q, index) => {
const optionsPromises = sanitizedData.questions!.map(async (q, index) => {
const question = questions[index];
if (!question) return;
@@ -165,11 +191,13 @@ export async function getExercise(
*/
export async function updateExerciseService(exerciseId: string, data: TExerciseUpdate): Promise<TExercise> {
try {
const sanitizedData = sanitizeExercisePayload(data);
return await db.transaction(async (tx) => {
const txClient = tx as DbOrTxClient;
// Update exercise basic fields only if they changed
const exerciseUpdate = buildExerciseUpdateFields(data);
const exerciseUpdate = buildExerciseUpdateFields(sanitizedData);
if (Object.keys(exerciseUpdate).length > 0) {
const updated = await updateExercise(exerciseId, exerciseUpdate, txClient);
if (!updated) {
@@ -178,7 +206,7 @@ export async function updateExerciseService(exerciseId: string, data: TExerciseU
}
// Process questions - only update what changed
if (data.questions && data.questions.length > 0) {
if (sanitizedData.questions && sanitizedData.questions.length > 0) {
// Query current state from DB
const { questions: currentQuestions, options: currentOptions } = await fetchQuestionsAndOptions(
exerciseId,
@@ -192,7 +220,7 @@ export async function updateExerciseService(exerciseId: string, data: TExerciseU
}));
// Compute diff between current DB state and incoming data
const diff = computeExerciseDiff(currentQuestionsWithOptions, data.questions);
const diff = computeExerciseDiff(currentQuestionsWithOptions, sanitizedData.questions);
// Sync option id sequence so next inserts get ids > max(id) (avoids option_pkey duplicate)
if (
@@ -240,10 +268,12 @@ export async function updateExerciseService(exerciseId: string, data: TExerciseU
*/
export async function replaceExerciseService(exerciseId: string, data: TExerciseUpdate): Promise<TExercise> {
try {
const sanitizedData = sanitizeExercisePayload(data);
return await db.transaction(async (tx) => {
const txClient = tx as DbOrTxClient;
const exerciseUpdate = buildExerciseUpdateFields(data);
const exerciseUpdate = buildExerciseUpdateFields(sanitizedData);
if (Object.keys(exerciseUpdate).length > 0) {
const updated = await updateExercise(exerciseId, exerciseUpdate, txClient);
if (!updated) {
@@ -260,7 +290,7 @@ export async function replaceExerciseService(exerciseId: string, data: TExercise
await deleteQuestionsByIds(currentQuestionIds, txClient);
}
const replacementQuestions = data.questions ?? [];
const replacementQuestions = sanitizedData.questions ?? [];
const hasReplacementOptions = replacementQuestions.some((question) => (question.options?.length ?? 0) > 0);
if (replacementQuestions.length > 0) {
@@ -389,14 +419,16 @@ export async function createExerciseFromTemplate(
const txClient = tx as DbOrTxClient;
for (const question of questions) {
const { title, question_type, options, order, points } = question;
const questionSettings = (question as { settings?: Record<string, unknown> }).settings ?? {};
const questionSettings = sanitizeUnknownStrings(
(question as { settings?: Record<string, unknown> }).settings ?? {}
);
// Create question
const [newQuestion] = await createQuestions(
[
{
exerciseId: exercise.id,
title,
title: sanitizeHtml(title),
questionTypeId: question_type.id,
points: points || 0,
order: order || 0,
@@ -415,9 +447,9 @@ export async function createExerciseFromTemplate(
// value column is UUID (default gen_random_uuid()); label is the display text only
const optionsData: TNewOption[] = options.map((opt) => ({
questionId: newQuestion.id,
label: opt.label || '',
label: sanitizeHtml(opt.label || ''),
isCorrect: opt.is_correct || false,
settings: (opt as { settings?: Record<string, unknown> }).settings ?? {}
settings: sanitizeUnknownStrings((opt as { settings?: Record<string, unknown> }).settings ?? {})
}));
await createOptions(optionsData, txClient);
+1
View File
@@ -79,6 +79,7 @@ export function buildExerciseUpdateFields(data: TExerciseUpdate): Partial<TExerc
if (data.order !== undefined) exerciseUpdate.order = data.order;
if (data.isUnlocked !== undefined) exerciseUpdate.isUnlocked = data.isUnlocked;
if (data.dueBy !== undefined) exerciseUpdate.dueBy = data.dueBy || null;
if (data.allowMultipleAttempts !== undefined) exerciseUpdate.allowMultipleAttempts = data.allowMultipleAttempts;
return exerciseUpdate;
}
+6 -1
View File
@@ -1,4 +1,5 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { sanitizeOptionalHtml } from '@api/utils/sanitize-html';
import type { TLessonLanguage, TLocale, TNewLessonLanguage } from '@db/types';
import {
getLessonLanguageByLessonIdAndLocale,
@@ -65,6 +66,7 @@ export async function upsertLessonLanguageService(
const language = await upsertLessonLanguage({
...data,
content: sanitizeOptionalHtml(data.content),
lessonId
});
@@ -105,7 +107,10 @@ export async function updateLessonLanguageService(
throw new AppError('Lesson language not found', ErrorCodes.LESSON_LANGUAGE_NOT_FOUND, 404);
}
const language = await updateLessonLanguage(lessonId, locale, data);
const language = await updateLessonLanguage(lessonId, locale, {
...data,
content: sanitizeOptionalHtml(data.content)
});
return language;
} catch (error) {
if (error instanceof AppError) {
+3 -2
View File
@@ -1,4 +1,5 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { sanitizeHtml } from '@api/utils/sanitize-html';
import type { TLesson, TNewLessonComment, TNewLessonCompletion } from '@cio/db/types';
import type { TLessonCreate, TLessonReorder, TLessonUpdate } from '@cio/utils/validation/lesson';
import {
@@ -230,7 +231,7 @@ export async function createLessonCommentService(lessonId: string, groupMemberId
const commentData: TNewLessonComment = {
lessonId,
groupmemberId: groupMemberId,
comment
comment: sanitizeHtml(comment)
};
return await createLessonComment(commentData);
@@ -252,7 +253,7 @@ export async function createLessonCommentService(lessonId: string, groupMemberId
*/
export async function updateLessonCommentService(commentId: number, comment: string) {
try {
const updated = await updateLessonComment(commentId, comment);
const updated = await updateLessonComment(commentId, sanitizeHtml(comment));
if (!updated) {
throw new AppError('Comment not found', ErrorCodes.LESSON_COMMENT_UPDATE_FAILED, 404);
}
+13 -5
View File
@@ -1,4 +1,5 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { sanitizeHtml } from '@api/utils/sanitize-html';
import type { TCourseNewsfeed, TNewCourseNewsfeed, TNewCourseNewsfeedComment } from '@cio/db/types';
import type { TNewsfeedCreate, TNewsfeedUpdate } from '@cio/utils/validation/newsfeed';
import {
@@ -93,10 +94,12 @@ export async function createNewsfeedService(
data: TNewsfeedCreate
): Promise<TCourseNewsfeed> {
try {
const sanitizedContent = sanitizeHtml(data.content);
const newsfeedData: TNewCourseNewsfeed = {
courseId,
authorId,
content: data.content,
content: sanitizedContent,
isPinned: data.isPinned || false
};
@@ -125,7 +128,10 @@ export async function createNewsfeedService(
*/
export async function updateNewsfeedService(feedId: string, data: TNewsfeedUpdate): Promise<TCourseNewsfeed> {
try {
const updated = await updateNewsfeed(feedId, data);
const updated = await updateNewsfeed(feedId, {
...data,
content: data.content === undefined ? undefined : sanitizeHtml(data.content)
});
if (!updated) {
// If no row was updated, it usually means the feed doesn't exist.
throw new AppError('Newsfeed item not found', ErrorCodes.INTERNAL_ERROR, 404);
@@ -256,16 +262,18 @@ export async function getNewsfeedCommentsService(feedId: string, options: { curs
*/
export async function createNewsfeedCommentService(feedId: string, authorId: string, content: string) {
try {
const sanitizedContent = sanitizeHtml(content);
const commentData: TNewCourseNewsfeedComment = {
courseNewsfeedId: feedId,
authorId,
content
content: sanitizedContent
};
const comment = await createNewsfeedComment(commentData);
// Send email notification to feed author (async, don't wait)
sendNewsfeedCommentEmail(feedId, content).catch((error) => {
sendNewsfeedCommentEmail(feedId, sanitizedContent).catch((error) => {
console.error('Failed to send newsfeed comment email:', error);
});
@@ -287,7 +295,7 @@ export async function createNewsfeedCommentService(feedId: string, authorId: str
*/
export async function updateNewsfeedCommentService(commentId: number, content: string) {
try {
const updated = await updateNewsfeedComment(commentId, content);
const updated = await updateNewsfeedComment(commentId, sanitizeHtml(content));
if (!updated) {
throw new AppError('Comment not found', ErrorCodes.NEWSFEED_COMMENT_UPDATE_FAILED, 404);
}
+64 -12
View File
@@ -1,6 +1,7 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import type { TNewOrganizationPlan, TOrganization, TOrganizationPlan } from '@db/types';
import {
deleteOrganizationAudienceMember,
cancelOrganizationPlan,
createOrganizationPlan,
deleteOrganizationMember,
@@ -34,7 +35,8 @@ import { createOrganizationWithOwner } from '@api/services/onboarding';
import { getProfileById } from '@cio/db/queries/auth';
import { inviteTeamMembers as inviteTeamMembersSecure } from './organization/invite';
import { deriveAudienceMemberStatus } from '@api/utils/audience-member-status';
import type { OrgAudienceMember } from '@api/types/org';
import type { OrgAudienceMember, OrgAudiencePagination, OrgAudienceQuery } from '@api/types/org';
import type { TGetAudienceQuery } from '@cio/utils/validation/organization';
/**
* Creates a new organization with the current user as owner
@@ -121,24 +123,43 @@ export async function getOrgTeam(orgId: string) {
* @param orgId - The organization ID
* @returns Audience members with invite status for profile-less rows
*/
export async function getOrgAudience(orgId: string): Promise<OrgAudienceMember[]> {
export async function getOrgAudience(
orgId: string,
query: TGetAudienceQuery
): Promise<{ items: OrgAudienceMember[]; pagination: OrgAudiencePagination; query: OrgAudienceQuery }> {
try {
const audience = await getOrganizationAudience(orgId);
const audienceResult = await getOrganizationAudience(orgId, query);
const audience = audienceResult.items;
const emailsWithoutProfile = audience.filter((m) => !m.profileId && m.email).map((m) => m.email.toLowerCase());
const invites = await getLatestOrgInvitesByEmails(orgId, emailsWithoutProfile);
const inviteByEmail = new Map(invites.map((i) => [i.email.toLowerCase(), i]));
return audience.map(
(member): OrgAudienceMember => ({
...member,
status: deriveAudienceMemberStatus(
member.profileId,
member.email ? inviteByEmail.get(member.email.toLowerCase()) : undefined
)
})
);
return {
items: audience.map(
(member): OrgAudienceMember => ({
...member,
status: deriveAudienceMemberStatus(
member.profileId,
member.email ? inviteByEmail.get(member.email.toLowerCase()) : undefined
)
})
),
pagination: {
page: audienceResult.page,
limit: audienceResult.limit,
total: audienceResult.total,
totalPages: audienceResult.totalPages
},
query: {
page: audienceResult.page,
limit: audienceResult.limit,
search: query.search,
sortBy: query.sortBy,
sortOrder: query.sortOrder
}
};
} catch (error) {
throw new AppError(
error instanceof Error ? error.message : 'Failed to fetch organization audience',
@@ -148,6 +169,37 @@ export async function getOrgAudience(orgId: string): Promise<OrgAudienceMember[]
}
}
/**
* Removes a student audience member from an organization
* @param orgId - The organization ID
* @param memberId - The student member ID to remove
* @returns Deleted member
*/
export async function removeAudienceMember(orgId: string, memberId: number, removedByProfileId?: string) {
try {
const deleted = await deleteOrganizationAudienceMember(orgId, memberId);
if (!deleted) {
throw new AppError('Audience member not found', ErrorCodes.ORG_AUDIENCE_REMOVE_FAILED, 404);
}
if (deleted.email && removedByProfileId) {
await revokeActiveOrganizationInvitesByEmails(orgId, [deleted.email.toLowerCase()], removedByProfileId);
}
return deleted;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to remove audience member',
ErrorCodes.ORG_AUDIENCE_REMOVE_FAILED,
500
);
}
}
/**
* Gets public courses for an organization (landing page)
* @param siteName - The organization siteName
+234 -123
View File
@@ -7,7 +7,6 @@ import type {
import { addGroupMembers, enrollUsersInCourseGroups, getExistingGroupMembers } from '@cio/db/queries/group';
import { buildEmailFromName, sendEmail } from '@cio/email';
import {
checkEmailsExistInOrg,
createOrganizationInvite,
createOrganizationInviteAudits,
createOrganizationInvites,
@@ -15,6 +14,7 @@ import {
getLatestOrganizationInviteRowByOrgAndEmail,
getOrgMembersByProfileIds,
getOrganizationById,
getOrganizationMembersByNormalizedEmails,
getStudentOrganizationMemberByOrgAndEmail,
hasActiveOrganizationInviteForEmail,
revokeActiveOrganizationInvitesByEmails
@@ -108,52 +108,9 @@ function getNormalizedRecipients(recipientCsv: string): {
return { valid, invalid, duplicates };
}
export async function importAudienceMembers(orgId: string, data: TImportAudienceMembers, invitedByProfileId: string) {
const organization = await getOrganizationById(orgId);
if (!organization || !organization.siteName) {
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
}
const recipients = getNormalizedRecipients(data.recipientCsv);
if (recipients.invalid.length > 0) {
throw new AppError(
`Invalid emails found: ${recipients.invalid.slice(0, 5).join(', ')}`,
ErrorCodes.VALIDATION_ERROR,
400,
'recipientCsv'
);
}
if (recipients.valid.length === 0) {
throw new AppError('No valid emails provided', ErrorCodes.VALIDATION_ERROR, 400, 'recipientCsv');
}
const existingEmails = await checkEmailsExistInOrg(orgId, recipients.valid);
const emailsToImport = recipients.valid.filter((email) => !existingEmails.includes(email));
if (emailsToImport.length === 0) {
return {
imported: 0,
skipped: existingEmails.length,
duplicates: recipients.duplicates.length,
emailsSent: 0,
emailsFailed: 0
};
}
await createOrganizationMembers(
emailsToImport.map((email) => ({
organizationId: orgId,
email,
roleId: ROLE.STUDENT,
verified: false
}))
);
async function resolveCourseIdsAndNamesForImport(orgId: string, data: TImportAudienceMembers) {
let courseIds: string[] = [];
let courseNames: string[] = [];
if (data.allCourses) {
const courses = await getOrgCourses({ orgId });
courseIds = courses.map((c) => c.id);
@@ -163,25 +120,108 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
courseIds = courses.map((c) => c.id);
courseNames = courses.map((c) => c.title).filter(Boolean);
}
if (courseIds.length > 0) {
const courseGroupMappings = await getCourseGroupIds(courseIds);
const validGroupIds = courseGroupMappings.map((m) => m.groupId).filter(Boolean) as string[];
return { courseIds, courseNames };
}
if (validGroupIds.length > 0) {
const profiles = await getProfilesByEmails(emailsToImport);
if (profiles.length > 0) {
const users = profiles.map((p) => ({ profileId: p.id, email: p.email ?? undefined }));
await enrollUsersInCourseGroups(validGroupIds, users, ROLE.STUDENT);
}
}
async function enrollAudienceStudentProfilesInCourses(
orgId: string,
organization: NonNullable<Awaited<ReturnType<typeof getOrganizationById>>>,
profileIds: string[],
courseIds: string[],
shouldSendEmail: boolean
): Promise<{ assigned: number; alreadyEnrolled: number; emailsSent: number }> {
if (courseIds.length === 0 || profileIds.length === 0) {
return { assigned: 0, alreadyEnrolled: 0, emailsSent: 0 };
}
await revokeActiveOrganizationInvitesByEmails(orgId, emailsToImport, invitedByProfileId);
const uniqueProfileIds = [...new Set(profileIds)];
const orgMembers = await getOrgMembersByProfileIds(orgId, uniqueProfileIds);
const studentMembers = orgMembers.filter((m) => m.profileId && m.roleId === ROLE.STUDENT);
const validProfileIds = new Set(studentMembers.map((m) => m.profileId!));
const profileEmailMap = new Map(studentMembers.filter((m) => m.profileId).map((m) => [m.profileId!, m.email ?? '']));
const courseGroups = await getOrgCourseGroups(orgId, courseIds);
if (courseGroups.length === 0) {
return { assigned: 0, alreadyEnrolled: 0, emailsSent: 0 };
}
const validGroupIds = courseGroups.map((cg) => cg.groupId).filter(Boolean) as string[];
const courseTitleByGroupId = new Map(courseGroups.map((cg) => [cg.groupId, cg.courseTitle]));
const validProfiles = uniqueProfileIds.filter((id) => validProfileIds.has(id));
const pairs = validProfiles.flatMap((profileId) => validGroupIds.map((groupId) => ({ groupId, profileId })));
const existingSet = await getExistingGroupMembers(pairs);
const toInsert = pairs.filter((p) => !existingSet.has(`${p.groupId}:${p.profileId}`));
const alreadyEnrolled = pairs.length - toInsert.length;
if (toInsert.length > 0) {
await addGroupMembers(
toInsert.map((p) => ({
groupId: p.groupId,
roleId: ROLE.STUDENT,
profileId: p.profileId,
email: profileEmailMap.get(p.profileId) || undefined
}))
);
}
let emailsSent = 0;
const loginUrl = getDashboardBaseUrl(organization.siteName ?? undefined);
if (shouldSendEmail && toInsert.length > 0) {
const emailPromises = toInsert
.filter((p) => profileEmailMap.get(p.profileId))
.map(async (p) => {
const email = profileEmailMap.get(p.profileId)!;
try {
await sendEmail('studentCourseWelcome', {
to: email,
fields: {
orgName: organization.name,
courseName: courseTitleByGroupId.get(p.groupId) || 'Course',
loginUrl
},
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
});
emailsSent++;
} catch (emailError) {
console.error(`enrollAudienceStudentProfilesInCourses email error for ${email}:`, emailError);
}
});
await Promise.all(emailPromises);
}
return {
assigned: toInsert.length,
alreadyEnrolled,
emailsSent
};
}
async function createStudentOrgInvitesAndSendEmails(input: {
orgId: string;
organization: NonNullable<Awaited<ReturnType<typeof getOrganizationById>>>;
emails: string[];
courseIds: string[];
courseNamesLabel: string | undefined;
invitedByProfileId: string;
shouldSendEmail: boolean;
}): Promise<{ created: number; emailsSent: number; emailsFailed: number }> {
const { orgId, organization, emails, courseIds, courseNamesLabel, invitedByProfileId, shouldSendEmail } = input;
if (emails.length === 0) {
return { created: 0, emailsSent: 0, emailsFailed: 0 };
}
await revokeActiveOrganizationInvitesByEmails(orgId, emails, invitedByProfileId);
const expiresAt = new Date(Date.now() + ORG_INVITE_EXPIRY_MS).toISOString();
const courseNamesLabel = courseNames.length > 0 ? courseNames.join(', ') : undefined;
const inviteInputs = emailsToImport.map((email) => {
const inviteInputs = emails.map((email) => {
const token = generateToken();
return {
email,
@@ -207,7 +247,7 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
const tokenByEmail = new Map(inviteInputs.map((i) => [i.email.toLowerCase(), i.token]));
await createOrganizationInviteAudits(
emailsToImport.map((email) => {
emails.map((email) => {
const invite = inviteByEmail.get(email)!;
return {
inviteId: invite.id,
@@ -230,8 +270,8 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
let emailsSent = 0;
let emailsFailed = 0;
if (data.sendEmail) {
const emailOutcomes = await mapWithConcurrency(emailsToImport, EMAIL_SEND_CONCURRENCY, async (email) => {
if (shouldSendEmail) {
const emailOutcomes = await mapWithConcurrency(emails, EMAIL_SEND_CONCURRENCY, async (email) => {
const invite = inviteByEmail.get(email)!;
const token = tokenByEmail.get(email)!;
try {
@@ -289,12 +329,140 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
);
}
return { created: emails.length, emailsSent, emailsFailed };
}
export async function importAudienceMembers(orgId: string, data: TImportAudienceMembers, invitedByProfileId: string) {
const organization = await getOrganizationById(orgId);
if (!organization || !organization.siteName) {
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
}
const recipients = getNormalizedRecipients(data.recipientCsv);
if (recipients.invalid.length > 0) {
throw new AppError(
`Invalid emails found: ${recipients.invalid.slice(0, 5).join(', ')}`,
ErrorCodes.VALIDATION_ERROR,
400,
'recipientCsv'
);
}
if (recipients.valid.length === 0) {
throw new AppError('No valid emails provided', ErrorCodes.VALIDATION_ERROR, 400, 'recipientCsv');
}
const memberRows = await getOrganizationMembersByNormalizedEmails(orgId, recipients.valid);
const memberByEmail = new Map(memberRows.map((m) => [m.normalizedEmail, m]));
const newEmails: string[] = [];
const existingStudentProfileIds: string[] = [];
const pendingStudentEmails: string[] = [];
const teamEmails: string[] = [];
for (const email of recipients.valid) {
const m = memberByEmail.get(email);
if (!m) {
newEmails.push(email);
continue;
}
if (m.roleId !== ROLE.STUDENT) {
teamEmails.push(email);
continue;
}
if (!m.profileId) {
pendingStudentEmails.push(email);
continue;
}
existingStudentProfileIds.push(m.profileId);
}
if (teamEmails.length > 0) {
throw new AppError(
`These emails belong to organization staff, not students: ${teamEmails.slice(0, 8).join(', ')}${teamEmails.length > 8 ? '…' : ''}`,
ErrorCodes.VALIDATION_ERROR,
400,
'recipientCsv'
);
}
const { courseIds, courseNames } = await resolveCourseIdsAndNamesForImport(orgId, data);
const courseNamesLabel = courseNames.length > 0 ? courseNames.join(', ') : undefined;
const assignResult = await enrollAudienceStudentProfilesInCourses(
orgId,
organization,
existingStudentProfileIds,
courseIds,
data.sendEmail
);
let imported = 0;
let importEmailsSent = 0;
let importEmailsFailed = 0;
if (newEmails.length > 0) {
await createOrganizationMembers(
newEmails.map((email) => ({
organizationId: orgId,
email,
roleId: ROLE.STUDENT,
verified: false
}))
);
if (courseIds.length > 0) {
const courseGroupMappings = await getCourseGroupIds(courseIds);
const validGroupIds = courseGroupMappings.map((m) => m.groupId).filter(Boolean) as string[];
if (validGroupIds.length > 0) {
const profiles = await getProfilesByEmails(newEmails);
if (profiles.length > 0) {
const users = profiles.map((p) => ({ profileId: p.id, email: p.email ?? undefined }));
await enrollUsersInCourseGroups(validGroupIds, users, ROLE.STUDENT);
}
}
}
const inviteOutcome = await createStudentOrgInvitesAndSendEmails({
orgId,
organization,
emails: newEmails,
courseIds,
courseNamesLabel,
invitedByProfileId,
shouldSendEmail: data.sendEmail
});
imported = newEmails.length;
importEmailsSent = inviteOutcome.emailsSent;
importEmailsFailed = inviteOutcome.emailsFailed;
}
let pendingEmailsSent = 0;
let pendingEmailsFailed = 0;
if (pendingStudentEmails.length > 0) {
const pendingOutcome = await createStudentOrgInvitesAndSendEmails({
orgId,
organization,
emails: pendingStudentEmails,
courseIds,
courseNamesLabel,
invitedByProfileId,
shouldSendEmail: data.sendEmail
});
pendingEmailsSent = pendingOutcome.emailsSent;
pendingEmailsFailed = pendingOutcome.emailsFailed;
}
return {
imported: emailsToImport.length,
skipped: existingEmails.length,
imported,
assigned: assignResult.assigned,
alreadyEnrolledInCourses: assignResult.alreadyEnrolled,
pendingInvitesRenewed: pendingStudentEmails.length,
duplicates: recipients.duplicates.length,
emailsSent,
emailsFailed
emailsSent: assignResult.emailsSent + importEmailsSent + pendingEmailsSent,
emailsFailed: importEmailsFailed + pendingEmailsFailed
};
}
@@ -465,67 +633,10 @@ export async function assignAudienceToCourses(orgId: string, data: TAssignAudien
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
}
const orgMembers = await getOrgMembersByProfileIds(orgId, data.profileIds);
const validProfileIds = new Set(orgMembers.map((m) => m.profileId).filter(Boolean));
const profileEmailMap = new Map(orgMembers.filter((m) => m.profileId).map((m) => [m.profileId!, m.email ?? '']));
const courseGroups = await getOrgCourseGroups(orgId, data.courseIds);
if (courseGroups.length === 0) {
throw new AppError('No valid courses found', ErrorCodes.VALIDATION_ERROR, 400, 'courseIds');
}
const validGroupIds = courseGroups.map((cg) => cg.groupId).filter(Boolean) as string[];
const courseTitleByGroupId = new Map(courseGroups.map((cg) => [cg.groupId, cg.courseTitle]));
const validProfiles = data.profileIds.filter((id) => validProfileIds.has(id));
const pairs = validProfiles.flatMap((profileId) => validGroupIds.map((groupId) => ({ groupId, profileId })));
const existingSet = await getExistingGroupMembers(pairs);
const toInsert = pairs.filter((p) => !existingSet.has(`${p.groupId}:${p.profileId}`));
const alreadyEnrolled = pairs.length - toInsert.length;
if (toInsert.length > 0) {
await addGroupMembers(
toInsert.map((p) => ({
groupId: p.groupId,
roleId: ROLE.STUDENT,
profileId: p.profileId,
email: profileEmailMap.get(p.profileId) || undefined
}))
);
}
let emailsSent = 0;
if (data.sendEmail && toInsert.length > 0) {
const emailPromises = toInsert
.filter((p) => profileEmailMap.get(p.profileId))
.map(async (p) => {
const email = profileEmailMap.get(p.profileId)!;
try {
await sendEmail('studentCourseWelcome', {
to: email,
fields: {
orgName: organization.name,
courseName: courseTitleByGroupId.get(p.groupId) || 'Course'
},
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
});
emailsSent++;
} catch (emailError) {
console.error(`assignAudienceToCourses email error for ${email}:`, emailError);
}
});
await Promise.all(emailPromises);
}
return {
assigned: toInsert.length,
alreadyEnrolled,
emailsSent
};
return enrollAudienceStudentProfilesInCourses(orgId, organization, data.profileIds, data.courseIds, data.sendEmail);
}
+78 -74
View File
@@ -1,7 +1,7 @@
import type { AnswerData, FileUploadAnswerData } from '@cio/question-types';
import { scoreSubmissionAnswers, type ScoreSubmissionResult, validateTextareaAnswer } from '@cio/question-types';
import { AppError, ErrorCodes } from '@api/utils/errors';
import type { TNewSubmission, TSubmission } from '@cio/db/types';
import type { TNewQuestionAnswer, TNewSubmission, TSubmission } from '@cio/db/types';
import type {
TSubmissionAnswerUpdate,
TSubmissionGradesUpdate,
@@ -15,10 +15,11 @@ import {
getSubmissionById,
getSubmissionsByCourseIdWithDetails,
getSubmissionsForGrading,
hasSubmission,
insertQuestionAnswersBatch,
updateQuestionAnswer,
updateSubmission,
updateSubmissionGrades,
upsertQuestionAnswer
updateSubmissionGrades
} from '@cio/db/queries/submission';
import {
fromApiPayload,
@@ -32,7 +33,7 @@ import { getExerciseById, getExerciseWithRelationsOptimized } from '@cio/db/quer
import { getGroupMemberIdByCourseAndProfile, isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group';
import { QUESTION_TYPE_ID_TO_KEY } from '@cio/question-types';
import { db } from '@cio/db/drizzle';
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { generateDocumentDownloadPresignedUrls } from '@api/utils/s3';
@@ -355,27 +356,26 @@ export async function listSubmissionsForGrading(courseId: string) {
timeStyle: 'medium'
}).format(new Date(submission.createdAt!));
// Format answers
const questionByIdAndName: { [id: number]: string } = {};
const questionKeyById: { [id: number]: string } = {};
const questionTypeById: { [id: number]: number } = {};
for (const question of submission.exercise.questions || []) {
questionByIdAndName[question.id] = question.name ?? '';
questionKeyById[question.id] = question.name ? String(question.name) : String(question.id);
questionTypeById[question.id] = question.questionTypeId;
}
const formattedAnswers: { [questionName: string]: AnswerData } = {};
const formattedAnswers: { [questionKey: string]: AnswerData } = {};
const questionAnswerByPoint: { [questionId: number]: number } = {};
for (const answer of submission.answers || []) {
const questionName = questionByIdAndName[answer.questionId];
const questionKey = questionKeyById[answer.questionId];
if (
questionName &&
questionKey &&
answer.answerData &&
typeof answer.answerData === 'object' &&
answer.answerData !== null &&
'type' in answer.answerData
) {
formattedAnswers[questionName] = answer.answerData as AnswerData;
formattedAnswers[questionKey] = answer.answerData as AnswerData;
}
questionAnswerByPoint[answer.questionId] = answer.point || 0;
}
@@ -475,11 +475,15 @@ export async function createSubmissionService(
answers: Array<{ questionId: number; optionId?: number; answer?: string }>
): Promise<TSubmission & { answers?: any[] }> {
try {
const exerciseWithRelations = await getExerciseWithRelationsOptimized(exerciseId);
const [exerciseWithRelations, courseRows] = await Promise.all([
getExerciseWithRelationsOptimized(exerciseId),
getCourseById(courseId)
]);
const course = courseRows[0];
if (!exerciseWithRelations.exercise.allowMultipleAttempts) {
const existing = await getSubmissionsByCourseIdWithDetails(courseId, exerciseId, submittedBy);
if (existing.length > 0) {
const alreadySubmitted = await hasSubmission(exerciseId, submittedBy);
if (alreadySubmitted) {
throw new AppError('This exercise allows only one submission', ErrorCodes.VALIDATION_ERROR, 400);
}
}
@@ -498,7 +502,7 @@ export async function createSubmissionService(
statusId: projectLegacyStatusId(gradingState),
gradingState,
overallStatus,
total: 0 // Will be calculated during grading
total: 0
};
let submission = await createSubmission(submissionData);
@@ -529,51 +533,45 @@ export async function createSubmissionService(
);
const answerByQuestionId = new Map<number, AnswerData>();
const answerRows: TNewQuestionAnswer[] = [];
// Create question answers
if (answers.length > 0) {
await db.transaction(async () => {
for (const ans of answers) {
const question = questionById.get(ans.questionId);
if (!question) continue;
for (const ans of answers) {
const question = questionById.get(ans.questionId);
if (!question) continue;
const answerData = fromApiPayload(
question.questionType,
{ questionId: ans.questionId, optionId: ans.optionId, answer: ans.answer },
question
const answerData = fromApiPayload(
question.questionType,
{ questionId: ans.questionId, optionId: ans.optionId, answer: ans.answer },
question
);
if (!answerData) continue;
if (answerData.type === 'TEXTAREA') {
const validation = validateTextareaAnswer(answerData.text, question);
if (!validation.isValid) {
throw new AppError(
validation.minCharacters !== undefined && validation.maxCharacters !== undefined
? `Paragraph answer must be between ${validation.minCharacters} and ${validation.maxCharacters} characters`
: validation.reason === 'below_min' && validation.minCharacters !== undefined
? `Paragraph answer must be at least ${validation.minCharacters} characters`
: `Paragraph answer must be at most ${validation.maxCharacters} characters`,
ErrorCodes.VALIDATION_ERROR,
400
);
if (!answerData) continue;
if (answerData.type === 'TEXTAREA') {
const validation = validateTextareaAnswer(answerData.text, question);
if (!validation.isValid) {
throw new AppError(
validation.minCharacters !== undefined && validation.maxCharacters !== undefined
? `Paragraph answer must be between ${validation.minCharacters} and ${validation.maxCharacters} characters`
: validation.reason === 'below_min' && validation.minCharacters !== undefined
? `Paragraph answer must be at least ${validation.minCharacters} characters`
: `Paragraph answer must be at most ${validation.maxCharacters} characters`,
ErrorCodes.VALIDATION_ERROR,
400
);
}
}
answerByQuestionId.set(ans.questionId, answerData);
await upsertQuestionAnswer({
submissionId: submission.id,
questionId: ans.questionId,
groupMemberId: submittedBy,
answerData
});
}
}
answerByQuestionId.set(ans.questionId, answerData);
answerRows.push({
submissionId: submission.id,
questionId: ans.questionId,
groupMemberId: submittedBy,
answerData
});
}
const courseRows = await getCourseById(courseId);
const course = courseRows[0];
const shouldAutoGrade =
course?.type === 'SELF_PACED' && overallStatus === 'auto_graded' && exerciseWithRelations.questions.length > 0;
@@ -582,7 +580,7 @@ export async function createSubmissionService(
for (const q of exerciseWithRelations.questions) {
if (q.id == null) continue;
if (!answeredIds.has(q.id)) {
await upsertQuestionAnswer({
answerRows.push({
submissionId: submission.id,
questionId: q.id,
groupMemberId: submittedBy,
@@ -590,7 +588,11 @@ export async function createSubmissionService(
});
}
}
}
const insertedAnswers = answerRows.length > 0 ? await insertQuestionAnswersBatch(answerRows) : [];
if (shouldAutoGrade) {
const dbQuestions = exerciseWithRelations.questions
.filter((q): q is typeof q & { id: number } => q.id != null)
.map((q) => ({
@@ -616,20 +618,28 @@ export async function createSubmissionService(
if (graded) {
submission = graded;
const pointByQuestionId = new Map(
scores.map((s: ScoreSubmissionResult['scores'][number]) => [s.questionId, s.points])
);
const answersWithPoints = insertedAnswers.map((row) => ({
...row,
point: pointByQuestionId.get(row.questionId) ?? row.point
}));
void sendExerciseSubmissionUpdateEmail(courseId, exerciseId, submittedBy).catch((emailError) => {
console.error('Failed to send exercise submission update email:', emailError);
});
const enrichedAnswers =
answersWithPoints.length > 0 ? await enrichFileUploadAnswersArray(answersWithPoints) : answersWithPoints;
return { ...submission, answers: enrichedAnswers };
}
}
// Send email notification to teachers
try {
await sendExerciseSubmissionUpdateEmail(courseId, exerciseId, submittedBy);
} catch (emailError) {
// Log but don't fail the request if email fails
void sendExerciseSubmissionUpdateEmail(courseId, exerciseId, submittedBy).catch((emailError) => {
console.error('Failed to send exercise submission update email:', emailError);
}
if (submission.gradingState === 'completed') {
return await getSubmission(submission.id);
}
});
return submission;
} catch (error) {
@@ -679,14 +689,10 @@ export async function updateSubmissionService(submissionId: string, data: TSubmi
throw new AppError('Failed to update submission', ErrorCodes.INTERNAL_ERROR, 500);
}
// Send email notification if status changed
if (statusChanged) {
try {
await sendSubmissionUpdateEmail(submissionId, nextLegacyStatusId);
} catch (emailError) {
// Log but don't fail the request if email fails
void sendSubmissionUpdateEmail(submissionId, nextLegacyStatusId).catch((emailError) => {
console.error('Failed to send submission update email:', emailError);
}
});
}
return updated;
@@ -782,11 +788,9 @@ export async function updateSubmissionGradesBatch(
}
if (statusChanged) {
try {
await sendSubmissionUpdateEmail(submissionId, targetStatusId);
} catch (emailError) {
void sendSubmissionUpdateEmail(submissionId, targetStatusId).catch((emailError) => {
console.error('Failed to send submission update email:', emailError);
}
});
}
return updated;
+16
View File
@@ -1,4 +1,5 @@
import type { AudienceMemberStatus } from '@api/utils/audience-member-status';
import type { TAudienceSortBy, TAudienceSortOrder } from '@cio/utils/validation/organization';
export type OrgAudienceMember = {
id: number;
@@ -9,3 +10,18 @@ export type OrgAudienceMember = {
createdAt: string;
status: AudienceMemberStatus;
};
export type OrgAudiencePagination = {
page: number;
limit: number;
total: number;
totalPages: number;
};
export type OrgAudienceQuery = {
page: number;
limit: number;
search?: string;
sortBy: TAudienceSortBy;
sortOrder: TAudienceSortOrder;
};
+89
View File
@@ -0,0 +1,89 @@
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import {
getCourseProgress as getCourseProgressQuery,
type TCourseCertificationRow
} from '@cio/db/queries/course/course';
import { setMemberCertificateEarned, setMemberCertificationEmailSent } from '@cio/db/queries/course/people';
import { getProfileById } from '@cio/db/queries/auth';
import { buildEmailFromName, sendEmail } from '@cio/email';
export function calcCourseProgressPercent(params: {
lessonsCompleted: number;
totalLessons: number;
exercisesCompleted: number;
exercisesCount: number;
}): number {
const totalItems = params.totalLessons + params.exercisesCount;
if (totalItems === 0) return 0;
const completedItems = params.lessonsCompleted + params.exercisesCompleted;
return Math.round((completedItems / totalItems) * 100);
}
export function isBeforeOrEqualDeadline(deadlineIso: string | null | undefined, now: Date): boolean {
if (!deadlineIso) return true;
const deadlineDate = new Date(deadlineIso);
if (Number.isNaN(deadlineDate.getTime())) return true;
return now.getTime() <= deadlineDate.getTime();
}
/**
* Persists certificate earned time, then sends the completion email and records
* {@link setMemberCertificationEmailSent}, without blocking the HTTP handler.
* Re-fetches progress so concurrent jobs can skip work already done.
*/
export function scheduleCertificationCompletionWork(params: {
courseId: string;
profileId: string;
groupMemberId: string;
courseRow: TCourseCertificationRow;
earnedAt: string;
}): void {
const { courseId, profileId, groupMemberId, courseRow, earnedAt } = params;
void (async () => {
try {
let latest = await getCourseProgressQuery(courseId, profileId);
if (!latest.certificateEarnedAt) {
await setMemberCertificateEarned(groupMemberId, earnedAt);
latest = await getCourseProgressQuery(courseId, profileId);
}
if (latest.certificationEmailSentAt) {
return;
}
const profile = await getProfileById(profileId);
const studentEmail = profile?.email;
if (!studentEmail) {
return;
}
const base = getDashboardBaseUrl(courseRow.orgSiteName ?? undefined);
const certificateUrl = `${base}/courses/${courseId}/certificates`;
await sendEmail('studentCourseCompletion', {
to: studentEmail,
fields: {
orgName: courseRow.orgName,
courseName: courseRow.title,
studentName: profile?.fullname || studentEmail,
certificateUrl,
customMessage: courseRow.certificate?.emailMessage ?? null
},
from: buildEmailFromName(`${courseRow.orgName} (via ClassroomIO.com)`)
});
await setMemberCertificationEmailSent(groupMemberId, new Date().toISOString());
} catch (error) {
console.error('scheduleCertificationCompletionWork: failed:', error);
}
})();
}
+1
View File
@@ -60,6 +60,7 @@ export const ErrorCodes = {
ORG_PLAN_NOT_FOUND: 'ORG_PLAN_NOT_FOUND',
ORG_TEAM_INVITE_FAILED: 'ORG_TEAM_INVITE_FAILED',
ORG_TEAM_REMOVE_FAILED: 'ORG_TEAM_REMOVE_FAILED',
ORG_AUDIENCE_REMOVE_FAILED: 'ORG_AUDIENCE_REMOVE_FAILED',
ORG_TEAM_EMAIL_EXISTS: 'ORG_TEAM_EMAIL_EXISTS',
ORG_TEAM_NOT_AUTHORIZED: 'ORG_TEAM_NOT_AUTHORIZED',
ORGANIZATION_API_KEY_NOT_FOUND: 'ORGANIZATION_API_KEY_NOT_FOUND',
+20 -7
View File
@@ -6,25 +6,38 @@ const client: RedisClientType = createClient({
url: env.REDIS_URL
});
let hasLoggedRedisUnavailable = false;
export function logRedisUnavailableOnce(message: string, error?: unknown): void {
if (hasLoggedRedisUnavailable) {
return;
}
hasLoggedRedisUnavailable = true;
if (error) {
console.warn(message, error);
return;
}
console.warn(message);
}
// Handle connection events
client.on('error', (err) => {
console.error('Redis Client Error:', err);
logRedisUnavailableOnce('Redis unavailable, Redis-backed features disabled', err);
});
client.on('connect', () => {
console.log('Redis Client Connected');
});
client.on('reconnecting', () => {
console.log('Redis Client Reconnecting');
});
// Track connection state
let isConnected = false;
export async function connectRedis(): Promise<void> {
if (!env.REDIS_URL) {
console.warn('REDIS_URL not set, Redis features disabled');
logRedisUnavailableOnce('REDIS_URL not set, Redis features disabled');
return;
}
if (isConnected) return;
@@ -32,7 +45,7 @@ export async function connectRedis(): Promise<void> {
await client.connect();
isConnected = true;
} catch (error) {
console.error('Redis connection failed, API will run without Redis:', error);
logRedisUnavailableOnce('Redis connection failed, API will run without Redis', error);
// Don't throw - allow API to start; rate limiting and caching will degrade gracefully
}
}
+3 -3
View File
@@ -9,7 +9,7 @@ import {
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { getPresignS3Client, getS3Client, getStorageConfig } from '@api/config/storage';
import { redis } from '@api/utils/redis/redis';
import { logRedisUnavailableOnce, redis } from '@api/utils/redis/redis';
export type GetSignedUrlParameters = Parameters<typeof getSignedUrl>;
@@ -70,7 +70,7 @@ export async function generateDownloadPresignedUrls(
try {
cachedValues = await redis.mGet(cacheKeys);
} catch (error) {
console.error('Redis mGet failed for presigned URL cache, falling back to S3:', error);
logRedisUnavailableOnce('Redis cache unavailable for presigned URLs, falling back to S3', error);
}
const keysToGenerate: string[] = [];
@@ -121,7 +121,7 @@ export async function generateDownloadPresignedUrls(
});
await pipeline.exec();
} catch (error) {
console.error('Redis setEx failed for presigned URL cache:', error);
logRedisUnavailableOnce('Redis cache write unavailable for presigned URLs, continuing without cache', error);
// URLs are already in signedUrls; cache write failure is non-fatal
}
+39
View File
@@ -0,0 +1,39 @@
import { createSanitizeHtmlConfig, stripSvgDataUrls } from '@cio/utils/functions';
import DOMPurify from 'isomorphic-dompurify';
export function sanitizeHtml(html: string): string {
let sanitized = DOMPurify.sanitize(html, createSanitizeHtmlConfig());
sanitized = stripSvgDataUrls(sanitized);
return sanitized;
}
export function sanitizeOptionalHtml(html: string | undefined): string | undefined;
export function sanitizeOptionalHtml(html: string | null | undefined): string | null | undefined;
export function sanitizeOptionalHtml(html: string | null | undefined): string | null | undefined {
if (html == null) return html;
return sanitizeHtml(html);
}
export function sanitizeUnknownStrings<T>(value: T): T {
if (typeof value === 'string') {
return sanitizeHtml(value) as T;
}
if (Array.isArray(value)) {
return value.map((item) => sanitizeUnknownStrings(item)) as T;
}
if (value && typeof value === 'object') {
const sanitizedEntries = Object.entries(value).map(([key, entryValue]) => [
key,
sanitizeUnknownStrings(entryValue)
]);
return Object.fromEntries(sanitizedEntries) as T;
}
return value;
}
+25
View File
@@ -180,6 +180,31 @@ html .dark {
@apply text-end;
}
.prose hr {
@apply my-6 border-t;
border-top-color: var(--border);
}
.prose pre {
@apply my-4 overflow-auto rounded-lg p-4;
background-color: var(--muted);
}
.dark .prose pre {
background-color: color-mix(in oklch, var(--muted) 30%, transparent);
}
.prose pre code {
@apply bg-transparent p-0 text-sm;
font-family: 'JetBrains Mono', monospace, Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono';
color: inherit;
}
.prose code:not(pre code) {
@apply rounded px-1.5 py-0.5 text-sm font-semibold;
background-color: var(--muted);
}
/* .dark .prose h1,
.dark .prose h2,
.dark .prose h3,
@@ -0,0 +1,42 @@
<script lang="ts">
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
import type { OrganizationAudienceMember } from '$features/org/utils/types';
interface Props {
open: boolean;
member: OrganizationAudienceMember | null;
isDeleting?: boolean;
onDelete: () => void | Promise<void>;
}
let { open = $bindable(false), member, isDeleting = false, onDelete }: Props = $props();
</script>
<Dialog.Root bind:open>
<Dialog.Content class="w-full max-w-md">
<Dialog.Header>
<Dialog.Title>{$t('audience.delete.title')}</Dialog.Title>
<Dialog.Description>{$t('audience.delete.description')}</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<p class="text-sm">
{$t('audience.delete.confirmation', {
email: member?.email ?? '',
name: member?.name ?? member?.email ?? ''
})}
</p>
<div class="flex justify-end gap-2">
<Button variant="outline" onclick={() => (open = false)} disabled={isDeleting}>
{$t('audience.delete.cancel')}
</Button>
<Button variant="destructive" onclick={onDelete} loading={isDeleting} disabled={!member || isDeleting}>
{$t('audience.delete.confirm')}
</Button>
</div>
</div>
</Dialog.Content>
</Dialog.Root>
@@ -20,16 +20,31 @@
selected: boolean;
onToggleSelect: () => void;
inviteActionEmail: string | null;
deletingMemberId: string | null;
canDeleteMembers: boolean;
onResendInvite: (email: string) => void;
onRevokeInvite: (email: string) => void;
onDelete: () => void;
}
let { row, memberDetailHref, selected, onToggleSelect, inviteActionEmail, onResendInvite, onRevokeInvite }: Props =
$props();
let {
row,
memberDetailHref,
selected,
onToggleSelect,
inviteActionEmail,
deletingMemberId,
canDeleteMembers,
onResendInvite,
onRevokeInvite,
onDelete
}: Props = $props();
const showInviteActions = $derived(
!row.profileId && (canResendAudienceInvite(row.status) || canRevokeAudienceInvite(row.status))
);
const isActionDisabled = $derived(inviteActionEmail === row.email || deletingMemberId === String(row.id));
const showRowActions = $derived(showInviteActions || canDeleteMembers);
</script>
<Table.Row>
@@ -55,31 +70,40 @@
</Table.Cell>
<Table.Cell>{row.createdAt}</Table.Cell>
<Table.Cell class="text-right">
{#if showInviteActions}
{#if showRowActions}
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="hover:ui:bg-muted inline-flex items-center justify-center rounded-md p-1.5"
aria-label={$t('audience.invite.row_actions_aria')}
disabled={inviteActionEmail !== null}
disabled={isActionDisabled}
onclick={(e) => e.stopPropagation()}
>
<EllipsisVerticalIcon class="ui:size-4 ui:text-muted-foreground" />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
{#if canResendAudienceInvite(row.status)}
<DropdownMenu.Item disabled={inviteActionEmail === row.email} onclick={() => onResendInvite(row.email)}>
<DropdownMenu.Item disabled={isActionDisabled} onclick={() => onResendInvite(row.email)}>
{$t('audience.invite.resend')}
</DropdownMenu.Item>
{/if}
{#if canRevokeAudienceInvite(row.status)}
<DropdownMenu.Item
class="ui:text-destructive focus:ui:text-destructive"
disabled={inviteActionEmail === row.email}
disabled={isActionDisabled}
onclick={() => onRevokeInvite(row.email)}
>
{$t('audience.invite.revoke')}
</DropdownMenu.Item>
{/if}
{#if canDeleteMembers}
<DropdownMenu.Item
class="ui:text-destructive focus:ui:text-destructive"
disabled={isActionDisabled}
onclick={onDelete}
>
{$t('audience.delete.action')}
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
@@ -1,5 +1,6 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import * as Select from '@cio/ui/base/select';
import { Search } from '@cio/ui/custom/search';
import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
@@ -8,10 +9,28 @@
hasSelection: boolean;
selectedCount: number;
searchValue?: string;
sortValue: string;
onSortChange: (value: string) => void;
onOpenAssign: () => void;
}
let { hasSelection, selectedCount, searchValue = $bindable(''), onOpenAssign }: Props = $props();
let {
hasSelection,
selectedCount,
searchValue = $bindable(''),
sortValue,
onSortChange,
onOpenAssign
}: Props = $props();
const sortOptions = [
{ label: t.get('audience.sort.newest'), value: 'createdAt:desc' },
{ label: t.get('audience.sort.oldest'), value: 'createdAt:asc' },
{ label: t.get('audience.sort.name_asc'), value: 'name:asc' },
{ label: t.get('audience.sort.name_desc'), value: 'name:desc' },
{ label: t.get('audience.sort.email_asc'), value: 'email:asc' },
{ label: t.get('audience.sort.email_desc'), value: 'email:desc' }
];
</script>
<Page.BodyHeader>
@@ -25,6 +44,22 @@
</Button>
</div>
{:else}
<Search placeholder={$t('audience.search_placeholder')} bind:value={searchValue} class="" />
<div class="flex w-full flex-col gap-2 md:flex-row md:items-center md:justify-between">
<Search placeholder={$t('audience.search_placeholder')} bind:value={searchValue} class="w-full md:max-w-sm" />
<Select.Root type="single" value={sortValue} onValueChange={onSortChange} name="audience-sort">
<Select.Trigger class="w-full md:w-[220px]">
{sortOptions.find((option) => option.value === sortValue)?.label ?? $t('audience.sort.label')}
</Select.Trigger>
<Select.Content>
<Select.Group>
{#each sortOptions as option (option.value)}
<Select.Item value={option.value} label={option.label} disabled={option.value === sortValue}>
{option.label}
</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
</div>
{/if}
</Page.BodyHeader>
@@ -20,8 +20,11 @@
isRowSelected: (rowId: string) => boolean;
onToggleRow: (rowId: string) => void;
inviteActionEmail: string | null;
deletingMemberId: string | null;
canDeleteMembers: boolean;
onResendInvite: (email: string) => void;
onRevokeInvite: (email: string) => void;
onDeleteRow: (row: OrganizationAudienceMember) => void;
}
let {
@@ -33,8 +36,11 @@
isRowSelected,
onToggleRow,
inviteActionEmail,
deletingMemberId,
canDeleteMembers,
onResendInvite,
onRevokeInvite
onRevokeInvite,
onDeleteRow
}: Props = $props();
function memberHref(row: OrganizationAudienceMember): string | null {
@@ -71,8 +77,11 @@
selected={isRowSelected(String(row.id))}
onToggleSelect={() => onToggleRow(String(row.id))}
{inviteActionEmail}
{deletingMemberId}
{canDeleteMembers}
{onResendInvite}
{onRevokeInvite}
onDelete={() => onDeleteRow(row)}
/>
{/each}
</Table.Body>
@@ -1,5 +1,6 @@
export { default as AssignCoursesModal } from './assign-courses-modal.svelte';
export { default as AudienceMemberRow } from './audience-member-row.svelte';
export { default as AudienceDeleteConfirmation } from './audience-delete-confirmation.svelte';
export { default as AudiencePagination } from './audience-pagination.svelte';
export { default as AudienceTable } from './audience-table.svelte';
export { default as AudienceTableSkeleton } from './audience-table-skeleton.svelte';
@@ -1,17 +1,25 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import UsersIcon from '@lucide/svelte/icons/users';
import { orgApi } from '$features/org/api/org.svelte';
import { t } from '$lib/utils/functions/translations';
import { Empty } from '@cio/ui/custom/empty';
import { UpgradeBanner } from '$features/ui';
import { currentOrgMaxAudience } from '$lib/utils/store/org';
import type { OrganizationAudience } from '$features/org/utils/types';
import { currentOrgMaxAudience, isOrgAdmin } from '$lib/utils/store/org';
import type {
OrganizationAudience,
OrganizationAudienceMember,
OrganizationAudiencePagination,
OrganizationAudienceQuery
} from '$features/org/utils/types';
import AssignCoursesModal from '$features/audience/components/assign-courses-modal.svelte';
import AudienceDeleteConfirmation from '$features/audience/components/audience-delete-confirmation.svelte';
import AudienceTableToolbar from '$features/audience/components/audience-table-toolbar.svelte';
import AudienceTableSkeleton from '$features/audience/components/audience-table-skeleton.svelte';
import AudienceTable from '$features/audience/components/audience-table.svelte';
import AudiencePagination from '$features/audience/components/audience-pagination.svelte';
import { SvelteSet } from 'svelte/reactivity';
import { DEFAULT_ORG_AUDIENCE_QUERY, getAudienceSearchParams } from '$features/org/utils/audience-query-utils';
interface Course {
id: string;
@@ -20,15 +28,17 @@
interface Props {
audience?: OrganizationAudience;
pagination?: OrganizationAudiencePagination | null;
query: OrganizationAudienceQuery;
courses?: Course[];
}
let { audience, courses = [] }: Props = $props();
let { audience, pagination = null, query, courses = [] }: Props = $props();
$effect(() => {
if (audience) {
orgApi.audience = audience;
}
orgApi.audience = audience ?? [];
orgApi.audiencePagination = pagination;
selectedIds.clear();
});
const headers = $derived([
@@ -39,11 +49,19 @@
]);
let inviteActionEmail = $state<string | null>(null);
let deletingMemberId = $state<string | null>(null);
let deleteCandidate = $state<OrganizationAudienceMember | null>(null);
let deleteDialogOpen = $state(false);
let searchValue = $state(query.search ?? '');
let sortValue = $state(`${query.sortBy}:${query.sortOrder}`);
async function handleResendInvite(email: string) {
inviteActionEmail = email;
try {
await orgApi.resendAudienceInvite({ email });
const result = await orgApi.resendAudienceInvite({ email });
if (result) {
await refreshAudience();
}
} finally {
inviteActionEmail = null;
}
@@ -52,32 +70,22 @@
async function handleRevokeInvite(email: string) {
inviteActionEmail = email;
try {
await orgApi.revokeAudienceInvite({ email });
const result = await orgApi.revokeAudienceInvite({ email });
if (result) {
await refreshAudience();
}
} finally {
inviteActionEmail = null;
}
}
let pageSize = $state(20);
let currentPage = $state(1);
let searchValue = $state('');
let selectedIds = new SvelteSet<string>();
let assignModalOpen = $state(false);
const filteredRows = $derived(
searchValue
? orgApi.audience.filter((row) =>
Object.values(row).some((val) => String(val).toLowerCase().includes(searchValue.toLowerCase()))
)
: orgApi.audience
);
const totalPages = $derived(Math.ceil(filteredRows.length / pageSize));
const startIndex = $derived((currentPage - 1) * pageSize);
const endIndex = $derived(startIndex + pageSize);
const paginatedRows = $derived(filteredRows.slice(startIndex, endIndex));
const selectablePageRows = $derived(paginatedRows.filter((row) => row.profileId));
const currentPage = $derived(pagination?.page ?? DEFAULT_ORG_AUDIENCE_QUERY.page);
const pageSize = $derived(pagination?.limit ?? DEFAULT_ORG_AUDIENCE_QUERY.limit);
const totalCount = $derived(pagination?.total ?? 0);
const selectablePageRows = $derived(orgApi.audience.filter((row) => row.profileId));
const allPageSelected = $derived(
selectablePageRows.length > 0 && selectablePageRows.every((row) => selectedIds.has(String(row.id)))
@@ -117,13 +125,110 @@
}
$effect(() => {
if (filteredRows.length > 0 && currentPage > totalPages) {
currentPage = 1;
}
searchValue = query.search ?? '';
sortValue = `${query.sortBy}:${query.sortOrder}`;
});
$effect(() => {
const normalizedSearch = searchValue.trim() || undefined;
const currentSearch = query.search ?? undefined;
if (normalizedSearch === currentSearch) {
return;
}
const timeoutId = setTimeout(() => {
void navigateAudience({
...query,
page: 1,
search: normalizedSearch
});
}, 300);
return () => clearTimeout(timeoutId);
});
async function refreshAudience(pageOverride = currentPage) {
await navigateAudience({
...query,
page: pageOverride
});
}
async function navigateAudience(nextQuery: OrganizationAudienceQuery) {
const searchParams = getAudienceSearchParams(nextQuery);
const nextSearch = searchParams.toString();
const currentSearch = page.url.searchParams.toString();
await goto(`${page.url.pathname}?${nextSearch}`, {
replaceState: nextSearch === currentSearch,
keepFocus: true,
noScroll: true,
invalidateAll: true
});
}
function handlePageChange(nextPage: number) {
if (nextPage === currentPage) {
return;
}
void navigateAudience({
...query,
page: nextPage
});
}
function handleSortChange(nextSortValue: string) {
if (nextSortValue === sortValue) {
return;
}
sortValue = nextSortValue;
const [sortBy, sortOrder] = nextSortValue.split(':') as [
OrganizationAudienceQuery['sortBy'],
OrganizationAudienceQuery['sortOrder']
];
void navigateAudience({
...query,
page: 1,
sortBy,
sortOrder
});
}
function openDeleteConfirmation(member: OrganizationAudienceMember) {
deleteCandidate = member;
deleteDialogOpen = true;
}
async function handleDeleteAudienceMember() {
if (!deleteCandidate) {
return;
}
deletingMemberId = String(deleteCandidate.id);
try {
const result = await orgApi.deleteAudienceMember(Number(deleteCandidate.id));
if (!result) {
return;
}
selectedIds.delete(String(deleteCandidate.id));
const nextPage = orgApi.audience.length === 1 && currentPage > 1 ? currentPage - 1 : currentPage;
await refreshAudience(nextPage);
deleteCandidate = null;
deleteDialogOpen = false;
} finally {
deletingMemberId = null;
}
}
</script>
{#if orgApi.audience.length >= $currentOrgMaxAudience}
{#if totalCount >= $currentOrgMaxAudience}
<UpgradeBanner>{$t('audience.upgrade')}</UpgradeBanner>
{/if}
@@ -131,34 +236,30 @@
{hasSelection}
selectedCount={selectedIds.size}
bind:searchValue
{sortValue}
onSortChange={handleSortChange}
onOpenAssign={() => (assignModalOpen = true)}
/>
{#if orgApi.audience.length || orgApi.isLoading}
{#if totalCount > 0}
<div class="w-full space-y-4">
{#if orgApi.isLoading}
<AudienceTableSkeleton rowCount={pageSize} />
{:else}
<AudienceTable
{headers}
rows={paginatedRows}
{allPageSelected}
{somePageSelected}
onToggleSelectAll={toggleSelectAll}
isRowSelected={(id) => selectedIds.has(id)}
onToggleRow={toggleRow}
{inviteActionEmail}
onResendInvite={handleResendInvite}
onRevokeInvite={handleRevokeInvite}
/>
{/if}
<AudiencePagination
count={filteredRows.length}
perPage={pageSize}
page={currentPage}
onPageChange={(p) => (currentPage = p)}
<AudienceTable
{headers}
rows={orgApi.audience}
{allPageSelected}
{somePageSelected}
onToggleSelectAll={toggleSelectAll}
isRowSelected={(id) => selectedIds.has(id)}
onToggleRow={toggleRow}
{inviteActionEmail}
{deletingMemberId}
canDeleteMembers={$isOrgAdmin === true}
onResendInvite={handleResendInvite}
onRevokeInvite={handleRevokeInvite}
onDeleteRow={openDeleteConfirmation}
/>
<AudiencePagination count={totalCount} perPage={pageSize} page={currentPage} onPageChange={handlePageChange} />
</div>
{:else}
<Empty title={$t('audience.no_audience')} description={$t('audience.manage')} icon={UsersIcon} variant="page" />
@@ -171,3 +272,10 @@
onclose={() => (assignModalOpen = false)}
onassigned={handleAssigned}
/>
<AudienceDeleteConfirmation
bind:open={deleteDialogOpen}
member={deleteCandidate}
isDeleting={deletingMemberId !== null}
onDelete={handleDeleteAudienceMember}
/>
@@ -9,6 +9,7 @@ import type {
EnrollCourseRequest,
GetCourseAnalyticsRequest,
GetCourseBySlugRequest,
GetCertificationEvaluationRequest,
GetCourseProgressRequest,
GetCourseRequest,
UpdateCourseData,
@@ -543,6 +544,27 @@ export class CourseApi extends BaseApiWithErrors {
});
}
async getCertificationEvaluation(courseId: string) {
return this.execute<GetCertificationEvaluationRequest>({
requestFn: () =>
classroomio.course[':courseId']['certification-evaluation'].$get({
param: { courseId }
}),
logContext: 'fetching certification evaluation',
onSuccess: (response) => {
if (response.data) {
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to load certification status');
}
}
});
}
/**
* Gets course analytics including student progress, completion rates, and grades
* @param courseId Course ID
@@ -635,7 +657,7 @@ export class CourseApi extends BaseApiWithErrors {
const lessonTabsOrder = metadata?.lessonTabsOrder;
if (lessonTabsOrder) {
const existingTabIds = lessonTabsOrder.map((tab) => tab.id);
const allTabs = [
const allTabs: Array<{ id: 1 | 2 | 3 | 4; name: string }> = [
{ id: 1, name: 'course.navItem.lessons.materials.tabs.note.title' },
{ id: 2, name: 'course.navItem.lessons.materials.tabs.slide.title' },
{ id: 3, name: 'course.navItem.lessons.materials.tabs.video.title' },
@@ -661,9 +683,15 @@ export class CourseApi extends BaseApiWithErrors {
};
}
// Ensure certificateTheme has default value
if (!data.certificateTheme) {
data.certificateTheme = 'professional';
if (!data.certificate) {
data.certificate = {
isDownloadable: false,
theme: 'professional'
};
}
if (!data.certificate.theme) {
data.certificate.theme = 'professional';
}
// Set the course data
@@ -32,7 +32,7 @@ export class ExerciseApi extends BaseApiWithErrors {
logContext: 'fetching exercise',
onSuccess: (response) => {
if (response.data) {
this.exercise = response.data;
this.exercise = response.data as Exercise;
this.success = true;
this.errors = {};
}
@@ -64,7 +64,7 @@ export class ExerciseApi extends BaseApiWithErrors {
logContext: 'creating exercise',
onSuccess: (response) => {
if (response.data) {
this.exercise = response.data;
this.exercise = response.data as Exercise;
snackbar.success('Exercise created successfully');
this.success = true;
this.errors = {};
@@ -106,7 +106,7 @@ export class ExerciseApi extends BaseApiWithErrors {
logContext: 'updating exercise',
onSuccess: (response) => {
if (response.data) {
this.exercise = response.data;
this.exercise = response.data as Exercise;
this.success = true;
this.errors = {};
}
@@ -163,7 +163,7 @@ export class ExerciseApi extends BaseApiWithErrors {
return;
}
await this.execute<SubmitExerciseRequest>({
return await this.execute<SubmitExerciseRequest>({
requestFn: () =>
classroomio.course[':courseId'].exercise[':exerciseId'].submission.$post({
param: { courseId, exerciseId },
@@ -220,7 +220,7 @@ export class ExerciseApi extends BaseApiWithErrors {
logContext: 'creating exercise from template',
onSuccess: (response) => {
if (response.data) {
this.exercise = response.data;
this.exercise = response.data as Exercise;
snackbar.success('Exercise created successfully');
this.success = true;
this.errors = {};
@@ -38,7 +38,6 @@ import {
import {
ZLessonCommentCreate,
ZLessonCommentUpdate,
ZLessonCompletionUpdate,
ZLessonCreate,
ZLessonReorder,
ZLessonUpdate
@@ -46,8 +45,8 @@ import {
import type { TLocale } from '@cio/db/types';
import { get } from 'svelte/store';
import { mediaApi } from '$features/media/api';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { mediaApi } from '$features/media/api';
import { profile } from '$lib/utils/store/user';
import { snackbar } from '$features/ui/snackbar/store';
@@ -607,17 +606,11 @@ export class LessonApi extends BaseApiWithErrors {
* Updates lesson completion
*/
async updateCompletion(courseId: string, lessonId: string, isComplete: boolean) {
const result = ZLessonCompletionUpdate.safeParse({ isComplete });
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'lesson');
return;
}
await this.execute<UpdateLessonCompletionRequest>({
return this.execute<UpdateLessonCompletionRequest>({
requestFn: () =>
classroomio.course[':courseId'].lesson[':lessonId'].completion.$put({
param: { courseId, lessonId },
json: result.data
json: { isComplete }
}),
logContext: 'updating lesson completion',
onSuccess: (response) => {
@@ -0,0 +1,134 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { Label } from '@cio/ui/base/label';
import * as RadioGroup from '@cio/ui/base/radio-group';
import { Button } from '@cio/ui/base/button';
import { Textarea } from '@cio/ui/base/textarea';
import * as Field from '@cio/ui/base/field';
import { t } from '$lib/utils/functions/translations';
import { courseApi } from '$features/course/api';
import { CERTIFICATE_THEME_IDS, type CertificateThemeId } from '$features/course/utils/constants';
import { parseCertificateThemeId } from '$features/course/utils/certificate-utils';
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
import Plain from './templates/plain.svelte';
import Professional from './templates/professional.svelte';
import BlueBadgePattern from './templates/blue-badge-pattern.svelte';
import PurpleBadgePattern from './templates/purple-badge-pattern.svelte';
import BlueProfessionalBadge from './templates/blue-professional-badge.svelte';
import PurpleProfessionalBadge from './templates/purple-professional-badge.svelte';
type Props = {
errors: Record<string, string>;
};
let { errors }: Props = $props();
const studentNamePlaceholder = 'Name of student';
const certificateTheme = $derived(parseCertificateThemeId(courseApi.course?.certificate?.theme));
const helperText = $derived(
`${courseApi.course?.description?.length || 0}/200 ${$t('course.navItem.certificates.characters')}`
);
function onDescriptionInput(e: Event) {
if (!courseApi.course) return;
courseApi.course.description = (e.currentTarget as HTMLTextAreaElement).value;
}
function goToOrgSettings() {
goto(
resolve('/org/[slug]/settings', {
slug: $currentOrg.siteName || ''
})
);
}
</script>
<div class="mb-3 flex h-4/5 w-full flex-1 flex-col justify-between gap-3 lg:flex-row">
<section class="h-full w-full lg:w-2/5">
<Field.Group class="w-full max-w-md! px-2">
<Field.Set>
<Field.Legend>{$t('course.navItem.certificates.certificate_settings')}</Field.Legend>
<Field.Group>
<Field.Field>
<Field.Label>{$t('course.navItem.certificates.theme')}</Field.Label>
<RadioGroup.Root
value={certificateTheme}
onValueChange={(value) => {
if (!courseApi.course) return;
courseApi.course.certificate = {
...(courseApi.course.certificate ?? {}),
theme: value as CertificateThemeId
};
}}
disabled={$isFreePlan}
class="mb-6"
>
<div class="flex flex-wrap justify-between gap-y-5">
{#each CERTIFICATE_THEME_IDS as theme (theme)}
<div class="mr-3 flex items-start space-x-2">
<RadioGroup.Item value={theme} id={theme} />
<Label for={theme} class="cursor-pointer">
<img src={`/images/certificate_theme_${theme}.png`} alt="" class="h-[82px] w-[110px]" />
</Label>
</div>
{/each}
</div>
</RadioGroup.Root>
</Field.Field>
<Field.Field>
<Field.Label>{$t('course.navItem.certificates.logo')}</Field.Label>
<Field.Description>
{$t('course.navItem.certificates.to_update')}
<strong class="font-semibold">{$t('course.navItem.certificates.settings')}</strong>
{$t('course.navItem.certificates.and_upload')}
</Field.Description>
<Button variant="outline" class="mt-3" onclick={goToOrgSettings}>
{$t('course.navItem.certificates.goto_settings')}
</Button>
</Field.Field>
<Field.Field>
<Field.Label for="cert-design-description">{$t('course.navItem.certificates.description')}</Field.Label>
<Textarea
id="cert-design-description"
rows={6}
class="w-full"
placeholder={$t('course.navItem.certificates.placeholder')}
value={courseApi.course?.description ?? ''}
oninput={onDescriptionInput}
disabled={$isFreePlan}
/>
<Field.Description>{helperText}</Field.Description>
{#if errors.description}
<Field.Error>{errors.description}</Field.Error>
{/if}
</Field.Field>
</Field.Group>
</Field.Set>
</Field.Group>
</section>
<section class="flex w-full rounded-md lg:w-3/5">
<div class="w-[95%]">
{#if certificateTheme === 'professional'}
<Professional studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'plain'}
<Plain studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'purpleProfessionalBadge'}
<PurpleProfessionalBadge studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'blueProfessionalBadge'}
<BlueProfessionalBadge studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'purpleBadgePattern'}
<PurpleBadgePattern studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'blueBadgePattern'}
<BlueBadgePattern studentName={studentNamePlaceholder} />
{/if}
</div>
</section>
</div>
@@ -0,0 +1,38 @@
<script lang="ts">
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
import { t } from '$lib/utils/functions/translations';
import { UpgradeBanner } from '$features/ui';
import CertificateDesign from './certificate-design.svelte';
import CertificateSettings from './certificate-settings.svelte';
type Props = {
errors: Record<string, string>;
};
let { errors }: Props = $props();
let activeTab = $state('design');
</script>
<UpgradeBanner>{$t('upgrade.certificate')}</UpgradeBanner>
<main class="px-2 md:-mr-3 md:-ml-3">
<UnderlineTabs.Root bind:value={activeTab} class="w-full">
<UnderlineTabs.List>
<UnderlineTabs.Trigger value="design">
{$t('course.navItem.certificates.tab_design')}
</UnderlineTabs.Trigger>
<UnderlineTabs.Trigger value="settings">
{$t('course.navItem.certificates.tab_settings')}
</UnderlineTabs.Trigger>
</UnderlineTabs.List>
<UnderlineTabs.Content value="design" class="mt-4">
<CertificateDesign {errors} />
</UnderlineTabs.Content>
<UnderlineTabs.Content value="settings" class="mt-4">
<CertificateSettings {errors} />
</UnderlineTabs.Content>
</UnderlineTabs.Root>
</main>
@@ -0,0 +1,186 @@
<script lang="ts">
import { Switch } from '@cio/ui/base/switch';
import { Input } from '@cio/ui/base/input';
import { Textarea } from '@cio/ui/base/textarea';
import * as Field from '@cio/ui/base/field';
import { t } from '$lib/utils/functions/translations';
import { courseApi } from '$features/course/api';
import { isFreePlan } from '$lib/utils/store/org';
import { getOrderedNavigableContent } from '$features/course/utils/content';
import { ContentType } from '@cio/utils/constants/content';
type Props = {
errors: Record<string, string>;
};
let { errors }: Props = $props();
function isoToDatetimeLocal(iso: string | null | undefined): string {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function updateCertificate(patch: Partial<NonNullable<typeof courseApi.course>['certificate']>) {
if (!courseApi.course) return;
courseApi.course.certificate = {
...(courseApi.course.certificate ?? {}),
...patch
};
}
function onCertDeadlineChange(e: Event) {
const v = (e.currentTarget as HTMLInputElement).value;
updateCertificate({ deadline: v ? new Date(v).toISOString() : null });
}
const certExercises = $derived(
getOrderedNavigableContent(courseApi.course).filter((item) => item.type === ContentType.Exercise)
);
const finalExerciseSelectValue = $derived(courseApi.course?.certificate?.requiredExerciseId ?? 'none');
function onFinalExerciseChange(e: Event) {
const v = (e.currentTarget as HTMLSelectElement).value;
if (!v || v === 'none') {
updateCertificate({ requiredExerciseId: null, exerciseMinScorePercent: null });
return;
}
const currentMin = courseApi.course?.certificate?.exerciseMinScorePercent;
updateCertificate({
requiredExerciseId: v,
exerciseMinScorePercent: currentMin ?? 100
});
}
function onThresholdInput(e: Event) {
const n = Number((e.currentTarget as HTMLInputElement).value);
updateCertificate({ threshold: Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : 100 });
}
function onMinExerciseScoreInput(e: Event) {
const n = Number((e.currentTarget as HTMLInputElement).value);
updateCertificate({ exerciseMinScorePercent: Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : 100 });
}
function onEmailMessageInput(e: Event) {
updateCertificate({ emailMessage: (e.currentTarget as HTMLTextAreaElement).value || null });
}
</script>
<Field.Group class="w-full max-w-md! px-2">
<Field.Set>
<Field.Legend>{$t('course.certification.rules_heading')}</Field.Legend>
<Field.Field orientation="horizontal">
<Switch
id="certificate-downloadable"
checked={!!courseApi.course?.certificate?.isDownloadable}
onCheckedChange={(checked) => {
updateCertificate({ isDownloadable: checked });
}}
disabled={$isFreePlan}
/>
<Field.Label for="certificate-downloadable" class="text-gray-600">
{$t('course.navItem.certificates.allow')}
</Field.Label>
</Field.Field>
<Field.Group>
<Field.Field>
<Field.Label for="cert-deadline">{$t('course.certification.deadline_label')}</Field.Label>
<Input
id="cert-deadline"
type="datetime-local"
class="w-full"
value={isoToDatetimeLocal(courseApi.course?.certificate?.deadline ?? null)}
onchange={onCertDeadlineChange}
disabled={$isFreePlan}
/>
<Field.Description>{$t('course.certification.deadline_helper')}</Field.Description>
{#if errors['certificate.deadline']}
<Field.Error>{errors['certificate.deadline']}</Field.Error>
{/if}
</Field.Field>
<Field.Field>
<Field.Label for="cert-threshold">{$t('course.certification.threshold_label')}</Field.Label>
<Input
id="cert-threshold"
type="number"
min={0}
max={100}
class="w-full"
value={String(courseApi.course?.certificate?.threshold ?? 100)}
oninput={onThresholdInput}
disabled={$isFreePlan}
/>
<Field.Description>{$t('course.certification.threshold_helper')}</Field.Description>
{#if errors['certificate.threshold']}
<Field.Error>{errors['certificate.threshold']}</Field.Error>
{/if}
</Field.Field>
<Field.Field>
<Field.Label for="cert-final-exercise">{$t('course.certification.final_exercise_label')}</Field.Label>
<select
id="cert-final-exercise"
class="ui:border-input ui:bg-background ui:text-foreground ui:ring-offset-background ui:shadow-xs ui:flex ui:h-9 ui:w-full ui:min-w-0 ui:rounded-md ui:border ui:px-3 ui:py-1 ui:text-base ui:outline-none ui:transition-[color,box-shadow] ui:disabled:cursor-not-allowed ui:disabled:opacity-50 ui:md:text-sm ui:focus-visible:border-ring ui:focus-visible:ring-ring/50 ui:focus-visible:ring-[3px]"
value={finalExerciseSelectValue}
onchange={onFinalExerciseChange}
disabled={$isFreePlan}
>
<option value="none">{$t('course.certification.final_exercise_none')}</option>
{#each certExercises as item (item.id)}
<option value={item.id}>{item.title}</option>
{/each}
</select>
<Field.Description>{$t('course.certification.final_exercise_helper')}</Field.Description>
</Field.Field>
{#if courseApi.course?.certificate?.requiredExerciseId}
<Field.Field>
<Field.Label for="cert-min-exercise-score">{$t('course.certification.min_exercise_score_label')}</Field.Label>
<Input
id="cert-min-exercise-score"
type="number"
min={0}
max={100}
class="w-full"
value={String(courseApi.course?.certificate?.exerciseMinScorePercent ?? 100)}
oninput={onMinExerciseScoreInput}
disabled={$isFreePlan}
/>
<Field.Description>{$t('course.certification.min_exercise_score_helper')}</Field.Description>
{#if errors['certificate.exerciseMinScorePercent']}
<Field.Error>{errors['certificate.exerciseMinScorePercent']}</Field.Error>
{/if}
</Field.Field>
{/if}
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('course.certification.email_message_label')}</Field.Legend>
<Field.Field>
<Textarea
id="cert-email-message"
class="w-full"
rows={4}
placeholder={$t('course.certification.email_message_placeholder')}
value={courseApi.course?.certificate?.emailMessage ?? ''}
oninput={onEmailMessageInput}
disabled={$isFreePlan}
/>
{#if errors['certificate.emailMessage']}
<Field.Error>{errors['certificate.emailMessage']}</Field.Error>
{/if}
</Field.Field>
</Field.Set>
</Field.Group>
@@ -0,0 +1,57 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
import { Confetti } from '$features/ui';
import { setConfetti } from '$features/ui/confetti/store';
import { courseCompletionModal, closeCourseCompletionModal } from '$features/course/store/course-completion-modal';
import { t } from '$lib/utils/functions/translations';
let open = $state(false);
let activeCourseId = $state('');
onMount(() => {
return courseCompletionModal.subscribe((v) => {
if (v?.open && v.courseId) {
activeCourseId = v.courseId;
open = true;
setConfetti(true);
setTimeout(() => setConfetti(false), 5500);
} else {
open = false;
}
});
});
function onOpenChange(next: boolean) {
open = next;
if (!next) {
closeCourseCompletionModal();
}
}
function goToCertificate() {
if (!activeCourseId) return;
onOpenChange(false);
goto(resolve(`/courses/${activeCourseId}/certificates`, {}));
}
</script>
<div class="pointer-events-none fixed inset-0 z-100">
<Confetti />
</div>
<Dialog.Root {open} {onOpenChange}>
<Dialog.Content class="ui:max-w-md">
<Dialog.Header>
<Dialog.Title>{$t('course.completion.modal.title')}</Dialog.Title>
<Dialog.Description>{$t('course.completion.modal.subtitle')}</Dialog.Description>
</Dialog.Header>
<Dialog.Footer class="ui:gap-2">
<Button variant="outline" onclick={() => onOpenChange(false)}>{$t('course.completion.modal.later')}</Button>
<Button onclick={goToCertificate}>{$t('course.completion.modal.view_certificate')}</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -1,196 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { Label } from '@cio/ui/base/label';
import { Switch } from '@cio/ui/base/switch';
import ZapIcon from '@lucide/svelte/icons/zap';
import * as RadioGroup from '@cio/ui/base/radio-group';
import { courseApi } from '$features/course/api';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
import { saveCertificateValidation } from '$lib/utils/functions/validator';
import { Button } from '@cio/ui/base/button';
import Plain from './templates/plain.svelte';
import Professional from './templates/professional.svelte';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { UpgradeBanner } from '$features/ui';
import BlueBadgePattern from './templates/blue-badge-pattern.svelte';
import PurpleBadgePattern from './templates/purple-badge-pattern.svelte';
import BlueProfessionalBadge from './templates/blue-professional-badge.svelte';
import PurpleProfessionalBadge from './templates/purple-professional-badge.svelte';
const studentNamePlaceholder = 'Name of student';
const themes = [
'professional',
'plain',
'purpleProfessionalBadge',
'blueProfessionalBadge',
'purpleBadgePattern',
'blueBadgePattern'
];
let errors = $state({
description: ''
});
const helperText = $derived(
`${courseApi.course?.description?.length || 0}/200 ${$t('course.navItem.certificates.characters')}`
);
let certificateTheme = $derived<string | undefined>(courseApi.course?.certificateTheme ?? undefined);
const saveCertificate = async () => {
if (!courseApi.course) return;
// Prevent free plan users from bypassing UI restrictions
if ($isFreePlan) {
errors.description = 'Certificate customization is only available on paid plans';
return;
}
try {
const result = saveCertificateValidation({
description: courseApi.course?.description || '',
isCertificateDownloadable: courseApi.course?.isCertificateDownloadable || false,
certificateTheme: certificateTheme || ''
});
if (result && Object.keys(result).length > 0) {
errors.description = $t(result.description) || $t('course.navItem.certificates.description_error');
throw new Error(errors.description);
}
errors.description = '';
await courseApi.update(courseApi.course.id!, {
description: courseApi.course?.description || '',
isCertificateDownloadable: courseApi.course?.isCertificateDownloadable || false,
certificateTheme: certificateTheme || ''
});
snackbar.success('snackbar.course_settings.success.saved');
} catch (error) {
if (error instanceof Error) {
errors.description = error.message;
} else {
errors.description = $t('course.navItem.certificates.unexpected_error');
}
}
};
</script>
<svelte:head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Qwitcher+Grypen&family=Roboto:wght@300;400;700&display=swap"
rel="stylesheet"
/>
</svelte:head>
<UpgradeBanner>{$t('upgrade.certificate')}</UpgradeBanner>
<main class="mt-2 px-2 md:-mr-3 md:-ml-3">
<div class="mb-3 flex h-4/5 w-full flex-1 flex-col justify-between gap-3 lg:flex-row">
<section class="h-full w-full lg:w-2/5">
<strong class="my-2 text-base font-semibold text-black dark:text-gray-100"
>{$t('course.navItem.certificates.certificate_settings')}</strong
>
<p class="my-4 text-xs font-normal dark:text-gray-100">
{$t('course.navItem.certificates.theme')}
</p>
<RadioGroup.Root bind:value={certificateTheme} disabled={$isFreePlan} class="mb-10">
<div class="flex flex-wrap justify-between gap-y-5">
{#each themes as theme}
<div class="mr-3 flex items-start space-x-2">
<RadioGroup.Item value={theme} id={theme} />
<Label for={theme} class="cursor-pointer">
<img src={`/images/certificate_theme_${theme}.png`} alt="themes" class="h-[82px] w-[110px]" />
</Label>
</div>
{/each}
</div>
</RadioGroup.Root>
<div>
<p class="my-2 text-xs font-normal text-black dark:text-gray-100">
{$t('course.navItem.certificates.logo')}
</p>
<div>
<p class="mt-1 text-base dark:text-gray-100">
{$t('course.navItem.certificates.to_update')}
<strong class="font-semibold">{$t('course.navItem.certificates.settings')}</strong>
{$t('course.navItem.certificates.and_upload')}
</p>
<Button variant="outline" class="mt-3" onclick={() => goto(`/org/${$currentOrg.siteName}/settings`)}>
{$t('course.navItem.certificates.goto_settings')}
</Button>
</div>
<span class="my-4">
<p class="mt-4 mb-2 text-xs font-normal dark:text-gray-100">
{$t('course.navItem.certificates.description')}
</p>
<TextareaField
rows={6}
placeholder={$t('course.navItem.certificates.placeholder')}
value={courseApi.course?.description}
onchange={(e) => (courseApi.course!.description = e.currentTarget.value)}
errorMessage={errors.description}
disabled={$isFreePlan}
helperMessage={helperText}
/>
</span>
<div class="my-4 flex items-center space-x-2">
<Switch
id="certificate-downloadable"
checked={!!courseApi.course?.isCertificateDownloadable}
onCheckedChange={(checked) => (courseApi.course!.isCertificateDownloadable = checked)}
disabled={$isFreePlan}
/>
<Label for="certificate-downloadable" class="text-sm font-medium dark:text-gray-100">
{$t('course.navItem.certificates.allow')}
</Label>
</div>
</div>
</section>
<section class="flex w-full items-center justify-center rounded-md bg-gray-100 lg:w-3/5 dark:bg-neutral-800">
<div class="certificate-container flex items-center justify-center">
{#if certificateTheme === 'professional'}
<Professional studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'plain'}
<Plain studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'purpleProfessionalBadge'}
<PurpleProfessionalBadge studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'blueProfessionalBadge'}
<BlueProfessionalBadge studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'purpleBadgePattern'}
<PurpleBadgePattern studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'blueBadgePattern'}
<BlueBadgePattern studentName={studentNamePlaceholder} />
{/if}
</div>
</section>
</div>
<div class="h-1/5">
<Button variant="secondary" onclick={saveCertificate} loading={courseApi.isLoading} disabled={$isFreePlan}>
{#if $isFreePlan}
<ZapIcon size={16} class="filled" />
{/if}
{$t('course.navItem.certificates.save')}
</Button>
</div>
</main>
<style>
p {
color: #262626;
}
.certificate-container {
width: 95%;
max-width: 95%;
}
</style>
@@ -5,7 +5,7 @@
</script>
<div>
{#if courseApi.course?.isCertificateDownloadable === true}
{#if courseApi.course?.certificate?.isDownloadable === true}
<UnlockedCertificate />
{:else}
<LockedCertificate />
@@ -5,17 +5,55 @@
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import { Button } from '@cio/ui/base/button';
import Empty from '@cio/ui/custom/empty/empty.svelte';
import { Empty } from '@cio/ui/custom/empty';
import { t } from '$lib/utils/functions/translations';
import { courseApi } from '$features/course/api';
import { snackbar } from '$features/ui/snackbar/store';
import { classroomio } from '$lib/utils/services/api';
import type { CertificationEvaluationData } from '$features/course/utils/types';
import { openCourseCompletionModal } from '$features/course/store/course-completion-modal';
let isLoading = $state(false);
let isCourseComplete = $state(false);
let evaluation = $state<CertificationEvaluationData | null>(null);
let courseId = $derived(courseApi.course?.id ?? '');
function formatBlockerMessage(blocker: CertificationEvaluationData['blockers'][number]): string {
const p = blocker.params ?? {};
switch (blocker.code) {
case 'CERT_PROGRESS':
return t.get('course.certification.blocker_progress', {
current: Number(p.current ?? 0),
required: Number(p.required ?? 0)
});
case 'CERT_DEADLINE_PASSED':
return t.get('course.certification.blocker_deadline');
case 'CERT_NO_CONTENT':
return t.get('course.certification.blocker_no_content');
case 'CERT_FINAL_EXERCISE_NOT_SUBMITTED':
return t.get('course.certification.blocker_final_not_submitted', {
exerciseTitle: String(p.exerciseTitle ?? '')
});
case 'CERT_FINAL_EXERCISE_PENDING_GRADE':
return t.get('course.certification.blocker_final_pending', {
exerciseTitle: String(p.exerciseTitle ?? '')
});
case 'CERT_FINAL_EXERCISE_SCORE':
return t.get('course.certification.blocker_final_score', {
exerciseTitle: String(p.exerciseTitle ?? ''),
bestPercent: Number(p.bestPercent ?? 0),
requiredPercent: Number(p.requiredPercent ?? 0)
});
case 'CERT_FINAL_EXERCISE_MISCONFIGURED':
return t.get('course.certification.blocker_final_misconfigured', {
exerciseTitle: String(p.exerciseTitle ?? '')
});
default:
return blocker.code;
}
}
const downLoadCertificate = async () => {
if (!isCourseComplete || !courseId) return;
@@ -24,7 +62,7 @@
const response = await classroomio.course[':courseId']['download']['certificate']['$post']({
param: { courseId },
json: {
theme: `${courseApi.course?.certificateTheme}`,
theme: `${courseApi.course?.certificate?.theme ?? 'professional'}`,
studentName: `${$profile.fullname}`,
courseName: `${courseApi.course?.title}`,
courseDescription: `${courseApi.course?.description}`,
@@ -52,22 +90,24 @@
isLoading = false;
};
const hasUserCompletedCourse = async () => {
const loadEvaluation = async () => {
if (!courseId) return;
isLoading = true;
const res = await courseApi.getCertificationEvaluation(courseId);
if (res?.data) {
evaluation = res.data;
const hasEarned = res.data.certificateEarnedAt != null;
isCourseComplete = hasEarned || !!res.data.eligibleForCertificate;
const response = await courseApi.getProgress(courseId, $profile.id);
if (courseApi.success && response?.data) {
const progress = response.data;
isCourseComplete =
progress.lessonsCount === progress.lessonsCompleted && progress.exercisesCount === progress.exercisesCompleted;
if (res.data.isNewCompletion && courseId) {
openCourseCompletionModal(courseId);
}
}
isLoading = false;
};
onMount(() => {
hasUserCompletedCourse();
loadEvaluation();
});
let title = $derived(
@@ -75,15 +115,40 @@
? 'course.navItem.certificates.unlocked_certificate'
: 'course.navItem.certificates.complete_to_download_title'
);
let subtitle = $derived(
isCourseComplete
? 'course.navItem.certificates.unlocked_certificate_subtitle'
: 'course.navItem.certificates.complete_to_download_subtitle'
);
let progressLines = $derived.by(() => {
if (!evaluation) {
return [
t.get('course.certificates.progress_hint', {
current: 0,
required: courseApi.course?.certificate?.threshold ?? 100
})
];
}
const blockers = evaluation.blockers ?? [];
if (blockers.length > 0) {
return blockers.map((b) => formatBlockerMessage(b));
}
return [
t.get('course.certificates.progress_hint', {
current: evaluation.progressPercent,
required: evaluation.certificationThreshold
})
];
});
</script>
<div class="flex-1">
<Empty title={$t(title)} description={$t(subtitle)} icon={DownloadIcon} variant="page">
<Empty title={$t(title)} icon={DownloadIcon} variant="page">
<div class="ui:text-muted-foreground mx-auto mb-4 max-w-md space-y-2 text-center text-sm">
{#if isCourseComplete}
<p>{$t('course.navItem.certificates.unlocked_certificate_subtitle')}</p>
{:else}
{#each progressLines as line (line)}
<p>{line}</p>
{/each}
{/if}
</div>
<Button onclick={downLoadCertificate} disabled={!isCourseComplete} loading={isLoading}>
<DownloadIcon size={16} />
{$t('course.navItem.certificates.download_certificate')}
@@ -155,7 +155,7 @@
</script>
<Dialog.Root bind:open={$contentCreateStore.open} onOpenChange={(isOpen) => !isOpen && closeModal()}>
<Dialog.Content class="flex max-h-[680px] w-fit! max-w-2xl! min-w-[400px] flex-col overflow-hidden">
<Dialog.Content class="flex max-h-[680px] w-full! max-w-xl! min-w-[400px] flex-col overflow-hidden">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.lessons.add_content')}</Dialog.Title>
</Dialog.Header>
@@ -252,7 +252,7 @@
bind:value={title}
autoFocus={true}
placeholder={$t('course.navItem.lessons.exercises.new_exercise_modal.title_placeholder')}
className="my-4 w-2/4!"
className="my-4 w-full"
/>
</div>
{:else if type === EXERCISE_CREATE_TYPE.TEMPLATE}
@@ -15,6 +15,7 @@
import { ContentType } from '@cio/utils/constants/content';
import { PREMIUM_QUESTION_TYPE_KEYS, QUESTION_TYPES } from '$features/ui/question/constants';
import { Button } from '@cio/ui/base/button';
import { Badge } from '@cio/ui/base/badge';
import { ExerciseQuestion } from '@cio/ui';
import OrderModal from './order-modal.svelte';
@@ -40,11 +41,20 @@
interface Props {
shouldDeleteExercise?: boolean;
exerciseId: any;
goBack?: any;
exerciseId: string;
goBack?: () => void;
/** Self-paced + all auto-gradable question types: enforce points &gt; 0 */
requiresPositivePointsForAutoGrade?: boolean;
selfPacedCourse?: boolean;
}
let { shouldDeleteExercise = $bindable(false), exerciseId, goBack = () => {} }: Props = $props();
let {
shouldDeleteExercise = $bindable(false),
exerciseId,
goBack = () => {},
requiresPositivePointsForAutoGrade = false,
selfPacedCourse = false
}: Props = $props();
let questionIdToDelete = $state(null);
let isDeleting = $state(false);
@@ -173,9 +183,10 @@
}
async function handleDelete() {
if (!courseApi.course?.id) return;
isDeleting = true;
await exerciseApi.delete(courseApi.course?.id!, exerciseId);
await exerciseApi.delete(courseApi.course.id, exerciseId);
if (exerciseApi.success) {
courseApi.removeContentItem(exerciseId, ContentType.Exercise);
@@ -234,6 +245,9 @@
bind:points={question.points}
hasError={!!errors[question.id]}
errorMsg={getQuestionErrorMsg(errors, question, 'points')}
pointsHint={requiresPositivePointsForAutoGrade && Number(question.points) === 0
? $t('course.navItem.lessons.exercises.all_exercises.points_required_auto_grade')
: null}
onPointsChange={() => {
question.isDirty = true;
}}
@@ -272,7 +286,7 @@
{getQuestionTypeLabel(question?.questionType)}
</Select.Trigger>
<Select.Content>
{#each QUESTION_TYPES as type, i (type.key)}
{#each QUESTION_TYPES as type (type.key)}
{#if $isFreePlan && PREMIUM_QUESTION_TYPE_KEYS.has(type.key)}
<button
type="button"
@@ -288,7 +302,27 @@
<span>{getQuestionTypeLabel(type)}</span>
</button>
{:else}
<Select.Item value={type.id.toString()} label={getQuestionTypeLabel(type)} />
<Select.Item value={type.id.toString()} label={getQuestionTypeLabel(type)}>
{#snippet children({ selected: _sel })}
<span class="flex w-full min-w-0 items-center justify-between gap-2">
<span class="truncate">{getQuestionTypeLabel(type)}</span>
{#if selfPacedCourse}
<Badge
variant={type.autoGradable ? 'success' : 'warning'}
class="ui:shrink-0 ui:text-[10px] ui:font-normal"
>
{type.autoGradable
? $t(
'course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_auto_gradable'
)
: $t(
'course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_manual_grading'
)}
</Badge>
{/if}
</span>
{/snippet}
</Select.Item>
{/if}
{/each}
</Select.Content>
@@ -0,0 +1,56 @@
<script lang="ts">
import { Switch } from '@cio/ui/base/switch';
import { Label } from '@cio/ui/base/label';
import { Button } from '@cio/ui/base/button';
import { questionnaire } from './store';
import { exerciseApi } from '$features/course/api';
import { courseApi } from '$features/course/api';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
type Props = {
exerciseId: string;
};
let { exerciseId }: Props = $props();
let saving = $state(false);
async function saveSettings() {
if (!courseApi.course?.id) return;
saving = true;
await exerciseApi.update(courseApi.course.id, exerciseId, {
allowMultipleAttempts: !!$questionnaire.allowMultipleAttempts
});
saving = false;
if (exerciseApi.success) {
snackbar.success('snackbar.exercise.success');
}
}
</script>
<div class="max-w-xl space-y-6">
<div class="flex items-center justify-between gap-4 rounded-lg border border-neutral-200 p-4 dark:border-neutral-700">
<div class="space-y-1">
<Label for="allow-multiple" class="text-sm font-medium dark:text-gray-100">
{$t('course.navItem.lessons.exercises.all_exercises.settings_allow_multiple')}
</Label>
<p class="ui:text-muted-foreground text-sm">
{$t('course.navItem.lessons.exercises.all_exercises.settings_allow_multiple_helper')}
</p>
</div>
<Switch
id="allow-multiple"
checked={!!$questionnaire.allowMultipleAttempts}
onCheckedChange={(checked) => {
questionnaire.update((q) => ({ ...q, allowMultipleAttempts: checked }));
}}
/>
</div>
<Button onclick={saveSettings} loading={saving} disabled={!courseApi.course?.id}>
{$t('course.navItem.lessons.exercises.all_exercises.settings_save')}
</Button>
</div>
@@ -54,6 +54,7 @@ export const questionnaire: Writable<{
isDescriptionDirty?: boolean;
questions: Question[];
totalSubmissions: number;
allowMultipleAttempts?: boolean;
}> = writable({
title: '',
dueBy: '',
@@ -62,7 +63,8 @@ export const questionnaire: Writable<{
description: '',
isDescriptionDirty: false,
questions: [],
totalSubmissions: 0
totalSubmissions: 0,
allowMultipleAttempts: false
});
export function clearQuestionnaireValidation() {
@@ -88,6 +90,15 @@ export function reset() {
clearQuestionnaireValidation();
}
/** Clears take-exercise state so the student can start another attempt (when allowed). */
export function resetStudentExerciseTake() {
questionnaireMetaData.set({
...initAnswerState,
exerciseId: null
});
clearQuestionnaireValidation();
}
/**
* Maps Zod validation errors to UI format: { questionId: { option: 'error message' } }
* This is used to display errors in the edit-mode component
@@ -29,9 +29,10 @@
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import { ContentType } from '@cio/utils/constants/content';
import { getOrderedNavigableContent } from '$features/course/utils/content';
import { globalStore } from '$lib/utils/store/app';
import { openCourseCompletionModal } from '$features/course/store/course-completion-modal';
import { ContentType } from '@cio/utils/constants/content';
import { toExerciseQuestionModel } from './question-type-utils';
import { getExerciseQuestionLabels } from './question-labels';
import axios from 'axios';
@@ -150,17 +151,13 @@
}));
}
courseApi.updateContentItem(exerciseId, ContentType.Exercise, { isComplete: true });
if (
$globalStore.isStudent &&
courseApi.course?.id &&
submitResult?.data &&
'certification' in submitResult.data
) {
const certification = (submitResult.data as { certification?: { isNewCompletion?: boolean } | null })
.certification;
if (certification?.isNewCompletion) {
openCourseCompletionModal(courseApi.course.id);
}
const allContentItems = getOrderedNavigableContent(courseApi.course);
const allComplete =
$globalStore.isStudent && allContentItems.length > 0 && allContentItems.every((item) => item.isComplete);
if (allComplete && courseApi.course?.id) {
openCourseCompletionModal(courseApi.course.id);
}
}
@@ -13,6 +13,7 @@
import { ContentType } from '@cio/utils/constants/content';
import { snackbar } from '$features/ui/snackbar/store';
import type { CourseContentItem } from '$features/course/utils/types';
import { openCourseCompletionModal } from '$features/course/store/course-completion-modal';
interface Props {
lessonId?: string;
@@ -65,7 +66,7 @@
const isLessonLocked = $derived(
$globalStore.isStudent && currentLessonItem && !(currentLessonItem.isUnlocked ?? false)
);
const isMarkCompleteDisabled = $derived(isMarkingComplete || isLessonLocked);
const isMarkCompleteDisabled = $derived(isMarkingComplete || isLessonLocked || isLessonComplete);
async function markLessonComplete(currentLessonId: string) {
isMarkingComplete = true;
@@ -78,8 +79,18 @@
await lessonApi.updateCompletion(courseId, currentLessonId, isComplete);
if (lessonApi.success) {
updateCourseContentCompletion(currentLessonId, isComplete);
snackbar.success('snackbar.lessons.success.complete_marked');
updateCourseContentCompletion(currentLessonId, isComplete);
const allComplete =
$globalStore.isStudent &&
isComplete &&
navigableContentItems.length > 0 &&
navigableContentItems.every((item) => item.isComplete);
if (allComplete) {
openCourseCompletionModal(courseId);
}
} else {
snackbar.error('snackbar.lessons.error.try_later');
}
@@ -12,12 +12,15 @@
interface Props {
id?: string;
slug?: string;
title?: string;
type?: string;
description?: string;
isPublished?: boolean;
totalLessons?: number;
totalStudents?: number;
isExplore?: boolean;
isLMS?: boolean;
tags?: Array<{
id: string;
name: string;
@@ -28,15 +31,32 @@
let {
id = '',
slug = '',
title = '',
type = '',
description = '',
isPublished = false,
totalLessons = 0,
totalStudents = 0,
isExplore = false,
isLMS = false,
tags = []
}: Props = $props();
function handleRowClick() {
if (isExplore) {
goto(resolve(`/course/${slug}`, {}));
return;
}
if (isLMS) {
goto(resolve(`/courses/${id}/lessons?next=true`, {}));
return;
}
goto(resolve(`/courses/[id]`, { id }));
}
function handleCloneCourse(e) {
e.stopPropagation();
// TODO: Clone course functionality
@@ -62,7 +82,7 @@
}
</script>
<Table.Row class="cursor-pointer" onclick={() => goto(resolve(`/courses/[id]`, { id }))}>
<Table.Row class="cursor-pointer" onclick={handleRowClick}>
<Table.Cell class="truncate"><p class="font-semibold">{title}</p></Table.Cell>
<Table.Cell class="truncate">
<p>{description}</p>
@@ -77,34 +97,38 @@
{/if}
</Table.Cell>
<Table.Cell>{totalLessons}</Table.Cell>
<Table.Cell>{totalStudents}</Table.Cell>
<Table.Cell>
<CoursePublishBadge {isPublished} />
{#if !isLMS}
<Table.Cell>{totalStudents}</Table.Cell>
<Table.Cell>
<CoursePublishBadge {isPublished} />
</Table.Cell>
{/if}
{/if}
{#if !isLMS}
<Table.Cell class="text-center">
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="flex items-center justify-center rounded-md p-1 hover:bg-gray-100"
onclick={(e) => e.stopPropagation()}
>
<EllipsisVerticalIcon size={16} />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={handleCloneCourse}>
{$t('courses.course_card.context_menu.clone')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleShareCourse}>
{$t('courses.course_card.context_menu.share')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleInvite}>
{$t('courses.course_card.context_menu.invite')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item class="text-red-600" onclick={handleDeleteCourse}>
{$t('courses.course_card.context_menu.delete')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
{/if}
<Table.Cell class="text-center">
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="flex items-center justify-center rounded-md p-1 hover:bg-gray-100"
onclick={(e) => e.stopPropagation()}
>
<EllipsisVerticalIcon size={16} />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={handleCloneCourse}>
{$t('courses.course_card.context_menu.clone')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleShareCourse}>
{$t('courses.course_card.context_menu.share')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleInvite}>
{$t('courses.course_card.context_menu.invite')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item class="text-red-600" onclick={handleDeleteCourse}>
{$t('courses.course_card.context_menu.delete')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
@@ -1,11 +1,14 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import { Button } from '@cio/ui/base/button';
import { CheckboxField } from '@cio/ui/custom/checkbox-field';
import { Search } from '@cio/ui/custom/search';
import { MultiSelectList } from '@cio/ui/custom/multi-select-list';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import { orgApi } from '$features/org/api/org.svelte';
import { currentOrg } from '$lib/utils/store/org';
import { DEFAULT_ORG_AUDIENCE_QUERY } from '$features/org/utils/audience-query-utils';
import { courseApi } from '$features/course/api';
import { profile } from '$lib/utils/store/user';
import type { OrgStudent } from './types';
@@ -20,29 +23,39 @@
let { courseId, students, isLoading = false, onAssigned }: Props = $props();
let searchValue = $state('');
let isSearching = $state(false);
let lastSearchValue = $state('');
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
let activeSearchRequestId = $state(0);
const selectedProfileIds = new SvelteSet<string>();
let sendEmail = $state(true);
let isSubmitting = $state(false);
const SEARCH_DEBOUNCE_MS = 350;
const filteredStudents = $derived.by(() => {
const query = searchValue.trim().toLowerCase();
const sortedStudents = [...students].sort((a, b) =>
const sortedStudents = $derived.by(() =>
[...students].sort((a, b) =>
(a.name || a.email || '').localeCompare(b.name || b.email || '', undefined, { sensitivity: 'base' })
);
if (!query) {
return sortedStudents;
}
return sortedStudents.filter((student) => {
const name = student.name?.toLowerCase() || '';
const email = student.email?.toLowerCase() || '';
return name.includes(query) || email.includes(query);
});
});
)
);
const showLoadingState = $derived(isLoading || isSearching);
const emptyMessage = $derived(
searchValue.trim()
? $t('course.navItem.people.invite_modal.no_matching_existing_students')
: $t('course.navItem.people.invite_modal.no_existing_students')
);
const selectedCount = $derived(selectedProfileIds.size);
const multiSelectItems = $derived(
sortedStudents
.filter((s): s is OrgStudent & { profileId: string } => Boolean(s.profileId))
.map((s) => ({
id: s.profileId,
label: s.name || s.email,
description: s.email
}))
);
function toggleStudent(profileId: string) {
if (selectedProfileIds.has(profileId)) {
selectedProfileIds.delete(profileId);
@@ -51,8 +64,70 @@
}
}
async function searchStudents(value: string) {
const normalizedValue = value.trim();
if (normalizedValue === lastSearchValue) {
return;
}
lastSearchValue = normalizedValue;
const orgId = $currentOrg.id;
if (!orgId) {
return;
}
const requestId = activeSearchRequestId + 1;
activeSearchRequestId = requestId;
isSearching = true;
try {
await orgApi.getOrgAudience(
orgId,
{
...DEFAULT_ORG_AUDIENCE_QUERY,
search: normalizedValue || undefined
},
{
abortPrevious: true
}
);
} finally {
if (requestId === activeSearchRequestId) {
isSearching = false;
}
}
}
function handleSearchValueChange(value: string) {
searchValue = value;
if (searchTimeout) {
clearTimeout(searchTimeout);
}
if (!value.trim()) {
void searchStudents(value);
return;
}
searchTimeout = setTimeout(() => {
void searchStudents(value);
}, SEARCH_DEBOUNCE_MS);
}
onDestroy(() => {
orgApi.cancelAudienceRequest();
if (!searchTimeout) {
return;
}
clearTimeout(searchTimeout);
});
async function assignStudents() {
if (!courseId) return;
if (selectedProfileIds.size === 0) {
snackbar.error('course.navItem.people.invite_modal.select_at_least_one_student');
return;
@@ -82,56 +157,33 @@
}
</script>
<div class="rounded-md border p-4">
<div class="space-y-1">
<p class="text-base font-semibold">{$t('course.navItem.people.invite_modal.existing_students_title')}</p>
<p class="ui:text-muted-foreground text-sm">
{$t('course.navItem.people.invite_modal.existing_students_description')}
</p>
</div>
<div class="mt-4 space-y-4">
<Search placeholder={$t('course.navItem.people.invite_modal.existing_students_search')} bind:value={searchValue} />
{#if selectedCount > 0}
<p class="ui:text-muted-foreground text-sm">{$t('audience.selected_count', { count: selectedCount })}</p>
{/if}
<div class="max-h-72 space-y-2 overflow-y-auto rounded-md border p-3">
{#if isLoading}
<p class="ui:text-muted-foreground text-sm">
{$t('course.navItem.people.invite_modal.loading_existing_students')}
</p>
{:else if students.length === 0}
<p class="ui:text-muted-foreground text-sm">{$t('course.navItem.people.invite_modal.no_existing_students')}</p>
{:else if filteredStudents.length === 0}
<p class="ui:text-muted-foreground text-sm">
{$t('course.navItem.people.invite_modal.no_matching_existing_students')}
</p>
{:else}
{#each filteredStudents as student (student.profileId)}
<div
class="ui:hover:bg-muted/40 rounded-md px-2 py-1"
role="button"
tabindex="0"
onclick={() => student.profileId && toggleStudent(student.profileId)}
onkeydown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
if (student.profileId) toggleStudent(student.profileId);
}
}}
>
<CheckboxField
name={`existing-student-${student.profileId}`}
label={student.name || student.email}
checked={student.profileId ? selectedProfileIds.has(student.profileId) : false}
>
<span class="ui:text-muted-foreground text-sm">{student.email}</span>
</CheckboxField>
</div>
{/each}
{/if}
<div class="">
<div class="space-y-2">
<div class="max-h-72 overflow-y-auto">
<MultiSelectList
class="border-0"
{emptyMessage}
items={multiSelectItems}
isLoading={showLoadingState}
isSelected={(id) => selectedProfileIds.has(id)}
onSearchValueChange={handleSearchValueChange}
onToggle={(id) => toggleStudent(id)}
namePrefix="existing-student"
listClass="max-h-60"
searchPlaceholder={$t('course.navItem.people.invite_modal.existing_students_search')}
bind:searchValue
>
{#snippet headingSnippet()}
<p class="ui:text-sm ui:font-medium">
{$t('course.navItem.people.invite_modal.existing_students_title')} ({multiSelectItems.length})
{#if selectedCount > 0}
<span class="ui:text-muted-foreground text-xs"
>{$t('audience.selected_count', { count: selectedCount })}</span
>
{/if}
</p>
{/snippet}
</MultiSelectList>
</div>
<CheckboxField
@@ -12,6 +12,7 @@
import { currentOrg } from '$lib/utils/store/org';
import { peopleApi } from '$features/course/api';
import { orgApi } from '$features/org/api/org.svelte';
import { DEFAULT_ORG_AUDIENCE_QUERY } from '$features/org/utils/audience-query-utils';
import type { OrgTeamMember } from '$lib/utils/types/org';
import type { OrgStudent, Tutor } from './types';
@@ -75,10 +76,11 @@
function loadStudents(orgId: string | undefined) {
if (!orgId) return;
untrack(async () => {
isLoadingStudents = true;
try {
await orgApi.getOrgAudience(orgId);
await orgApi.getOrgAudience(orgId, DEFAULT_ORG_AUDIENCE_QUERY, { abortPrevious: true });
} finally {
isLoadingStudents = false;
}
@@ -125,7 +127,7 @@
if (!open) closeModal();
}}
>
<Dialog.Content class="max-h-[80vh] w-[96vw] max-w-6xl overflow-y-auto">
<Dialog.Content class="max-h-[80vh] w-[96vw] max-w-3xl! overflow-y-auto">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.people.invite_modal.title')}</Dialog.Title>
</Dialog.Header>
@@ -11,6 +11,8 @@
points?: any;
hasError?: boolean;
errorMsg?: string | null;
/** Extra hint under points row (e.g. auto-grade requires non-zero points) */
pointsHint?: string | null;
onPointsChange?: any;
key?: string;
children?: import('svelte').Snippet;
@@ -23,6 +25,7 @@
points = $bindable(undefined),
hasError = false,
errorMsg = null,
pointsHint = null,
onPointsChange = () => {},
children
}: Props = $props();
@@ -67,6 +70,8 @@
{#if errorMsg}
<p class="text-xs text-red-500">{errorMsg}</p>
{:else if pointsHint}
<p class="ui:text-muted-foreground max-w-[min(100%,12rem)] text-xs">{pointsHint}</p>
{/if}
{#if onClose && !isTitle}
@@ -64,15 +64,17 @@
<span>{$t('course.navItems.nav_content')}</span>
<div class="ml-auto flex items-center gap-1">
<Plus
size={20}
class="rounded-full p-1"
onclick={(event) => {
event.preventDefault();
event.stopPropagation();
openContentModal(id);
}}
/>
{#if !isStudent}
<Plus
size={20}
class="rounded-full p-1"
onclick={(event) => {
event.preventDefault();
event.stopPropagation();
openContentModal(id);
}}
/>
{/if}
</div>
</a>
{/snippet}
@@ -85,8 +87,8 @@
{id}
{isStudent}
className="mt-1"
onOpenContentModal={(sectionId) => openContentModal(id, sectionId)}
onEditSection={(sectionId) => openSectionEditor(id, sectionId)}
onOpenContentModal={isStudent ? undefined : (sectionId) => openContentModal(id, sectionId)}
onEditSection={isStudent ? undefined : (sectionId) => openSectionEditor(id, sectionId)}
/>
</Sidebar.Group>
@@ -26,6 +26,7 @@
let { path, id, isStudent = false, className = '', onOpenContentModal }: Props = $props();
const contentData = $derived(getCourseContent(courseApi.course));
const canOpenContentModal = $derived(Boolean(onOpenContentModal) && !isStudent);
</script>
<Sidebar.MenuSub class={className}>
@@ -42,7 +43,7 @@
<span class="flex-1 truncate">{section.title}</span>
<div class="ml-auto flex items-center gap-1">
{#if onOpenContentModal}
{#if canOpenContentModal}
<IconButton
variant="ghost-outline"
size="icon-xs"
@@ -285,8 +285,8 @@
{id}
{isStudent}
className="mt-1 ml-2"
onOpenContentModal={(sectionId) => openContentModal(id, sectionId)}
onEditSection={(sectionId) => openSectionEditor(id, sectionId)}
onOpenContentModal={isStudent ? undefined : (sectionId) => openContentModal(id, sectionId)}
onEditSection={isStudent ? undefined : (sectionId) => openSectionEditor(id, sectionId)}
/>
{/if}
</Sidebar.MenuItem>
@@ -1,14 +1,20 @@
<script lang="ts">
import IssueCertificateModal from '$features/course/components/ceritficate/issue-certificate-modal.svelte';
import { RoleBasedSecurity } from '$features/ui';
import Design from '$features/course/components/ceritficate/design.svelte';
import CertificateEdit from '$features/course/components/ceritficate/certificate-edit.svelte';
import StudentCertificate from '$features/course/components/ceritficate/student-certificate/index.svelte';
type Props = {
errors: Record<string, string>;
};
let { errors }: Props = $props();
</script>
<IssueCertificateModal />
<RoleBasedSecurity allowedRoles={[1, 2]}>
<Design />
<CertificateEdit {errors} />
</RoleBasedSecurity>
<RoleBasedSecurity onlyStudent allowedRoles={[3]}>
<StudentCertificate />
@@ -110,20 +110,18 @@
{/if}
{@render filterControls?.()}
{#if !isLMS}
{#if $courseMetaDeta.view === 'list'}
<IconButton onclick={() => setViewPreference('grid')}>
<GridIcon size={16} />
</IconButton>
{:else}
<IconButton onclick={() => setViewPreference('list')}>
<ListIcon size={16} />
</IconButton>
{/if}
{#if $courseMetaDeta.view === 'list'}
<IconButton onclick={() => setViewPreference('grid')}>
<GridIcon size={16} />
</IconButton>
{:else}
<IconButton onclick={() => setViewPreference('list')}>
<ListIcon size={16} />
</IconButton>
{/if}
</Page.BodyHeader>
<div class="mx-auto w-full flex-1">
<div class="mx-auto mt-4 w-full flex-1">
{#if isLoading}
<section class={`${isLoading || courses ? 'cards-container' : ''} `}>
<CourseCardLoader />
@@ -146,15 +144,18 @@
<Table.Head class="w-[10%]">{$t('courses.course_card.list_view.type')}</Table.Head>
<Table.Head class="w-3/12 min-w-0">{$t('courses.course_card.list_view.tags')}</Table.Head>
<Table.Head class="w-[8%]">{$t('courses.course_card.list_view.lessons')}</Table.Head>
<Table.Head class="w-[8%]">{$t('courses.course_card.list_view.students')}</Table.Head>
<Table.Head class="w-[10%]">{$t('courses.course_card.list_view.published')}</Table.Head>
<Table.Head class="w-[6%]"></Table.Head>
{#if !isLMS}
<Table.Head class="w-[8%]">{$t('courses.course_card.list_view.students')}</Table.Head>
<Table.Head class="w-[10%]">{$t('courses.course_card.list_view.published')}</Table.Head>
<Table.Head class="w-[6%]"></Table.Head>
{/if}
</Table.Row>
</Table.Header>
<Table.Body>
{#each courses as courseData (courseData.id)}
<CourseList
id={courseData.id}
slug={courseData.slug ?? ''}
title={courseData.title}
type={$t(`course.navItem.settings.${courseData.type?.toLowerCase()}`)}
description={courseData.description}
@@ -167,6 +168,8 @@
slug: string;
color?: string | null;
}>}
{isExplore}
{isLMS}
/>
{/each}
</Table.Body>
@@ -30,6 +30,7 @@
import { exerciseApi } from '$features/course/api';
import { courseApi } from '$features/course/api';
import EditMode from '$features/course/components/exercise/edit-mode.svelte';
import ExerciseSettingsTab from '$features/course/components/exercise/exercise-settings-tab.svelte';
import ViewMode from '$features/course/components/exercise/view-mode.svelte';
import Submissions from '$features/course/components/exercise/submissions/submissions.svelte';
import { IconButton } from '@cio/ui/custom/icon-button';
@@ -41,17 +42,19 @@
import VideoIcon from '@lucide/svelte/icons/video';
import {
getQuestionTypeId,
getQuestionTypeKeyFromId,
getQuestionTypeOptionById
} from '$features/course/components/exercise/question-type-utils';
import type { SubmissionListItem } from '$features/course/utils/types';
import { Badge } from '@cio/ui/base/badge';
import { isAutoGradableQuestionType } from '@cio/question-types';
interface Props {
exerciseId?: string;
goBack?: () => void;
isFetching?: boolean;
submissions: SubmissionListItem[];
mySubmission: SubmissionListItem | null;
mySubmissions?: SubmissionListItem[];
}
let {
@@ -59,15 +62,18 @@
goBack = () => {},
isFetching = false,
submissions,
mySubmission
mySubmissions = []
}: Props = $props();
type ExerciseTab = 'questions' | 'submissions';
type ExerciseTab = 'questions' | 'settings' | 'submissions';
function normalizeExerciseTab(tabParam: string | null): ExerciseTab {
if (tabParam === 'submissions' || tabParam === 'analytics') {
return 'submissions';
}
if (tabParam === 'settings') {
return 'settings';
}
return 'questions';
}
@@ -109,6 +115,26 @@
return;
}
if (requiresPositivePointsForAutoGrade) {
const zeroPointQuestions = ($questionnaire.questions ?? []).filter((q) => !q.deletedAt && Number(q.points) === 0);
if (zeroPointQuestions.length > 0) {
const pointErrors: Record<string, { option?: string; title?: string; points?: string }> = {};
for (const q of zeroPointQuestions) {
pointErrors[q.id] = {
points: t.get('course.navItem.lessons.exercises.all_exercises.points_required_auto_grade')
};
}
questionnaireValidation.set(pointErrors);
snackbar.error('snackbar.exercise.points_required_auto_grade');
return;
}
}
isSaving = true;
reset();
@@ -204,11 +230,41 @@
)
);
const isExerciseUnlocked = $derived(exerciseContentItem?.isUnlocked ?? false);
const isSelfPacedCourse = $derived(courseApi.course?.type === 'SELF_PACED');
const activeQuestionTypeIds = $derived(
($questionnaire.questions ?? []).filter((q) => !q.deletedAt).map((q) => getQuestionTypeId(q))
);
function isExerciseFullyAutoGradable(typeIds: number[]): boolean {
return typeIds.length > 0 && typeIds.every((id) => isAutoGradableQuestionType(getQuestionTypeKeyFromId(id)));
}
/** When true, every question must have points &gt; 0 before save */
const requiresPositivePointsForAutoGrade = $derived(
isSelfPacedCourse && isExerciseFullyAutoGradable(activeQuestionTypeIds)
);
const teacherAutoGradeBadge = $derived.by(() => {
if ($globalStore.isStudent || !isSelfPacedCourse || activeQuestionTypeIds.length === 0) return null;
return isExerciseFullyAutoGradable(activeQuestionTypeIds) ? ('auto' as const) : ('manual' as const);
});
</script>
<Page.Header isSticky={true} class="z-250! min-h-[36px]">
<Page.HeaderContent>
<Page.Title>{$questionnaire.title || 'Exercise'}</Page.Title>
<Page.Title class="flex flex-wrap items-center gap-2">
<span>{$questionnaire.title || 'Exercise'}</span>
<RoleBasedSecurity allowedRoles={[1, 2]}>
{#if teacherAutoGradeBadge === 'auto'}
<Badge variant="success" class="font-normal">
{$t('course.navItem.lessons.exercises.all_exercises.auto_graded_badge')}
</Badge>
{:else if teacherAutoGradeBadge === 'manual'}
<Badge variant="warning" class="font-normal">
{$t('course.navItem.lessons.exercises.all_exercises.manual_grading_required_badge')}
</Badge>
{/if}
</RoleBasedSecurity>
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<div class="flex items-center gap-2">
@@ -278,7 +334,7 @@
<div class="overflow-x-hidden">
{#if $globalStore.isStudent}
{#if isExerciseUnlocked}
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmission} />
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmissions} />
{:else}
<Empty
title={$t('course.navItem.lessons.content_locked_title')}
@@ -290,10 +346,13 @@
{/if}
{:else}
<UnderlineTabs.Root bind:value={selectedTab} class="mb-4">
<UnderlineTabs.List class="grid w-full max-w-xs grid-cols-2">
<UnderlineTabs.List class="grid w-full max-w-lg grid-cols-3">
<UnderlineTabs.Trigger value="questions">
{$t('course.navItem.lessons.exercises.all_exercises.questions')}
</UnderlineTabs.Trigger>
<UnderlineTabs.Trigger value="settings">
{$t('course.navItem.lessons.exercises.all_exercises.settings')}
</UnderlineTabs.Trigger>
<UnderlineTabs.Trigger value="submissions">
{$t('course.navItem.lessons.exercises.all_exercises.analytics.submissions')}
@@ -303,11 +362,20 @@
<UnderlineTabs.Content value="questions">
<UpdateDescription {preview} />
{#if !preview}
<EditMode bind:shouldDeleteExercise {exerciseId} {goBack} />
<EditMode
bind:shouldDeleteExercise
{exerciseId}
{goBack}
{requiresPositivePointsForAutoGrade}
selfPacedCourse={isSelfPacedCourse}
/>
{:else}
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmission} />
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmissions} />
{/if}
</UnderlineTabs.Content>
<UnderlineTabs.Content value="settings">
<ExerciseSettingsTab {exerciseId} />
</UnderlineTabs.Content>
<UnderlineTabs.Content value="submissions">
<Submissions bind:exerciseId {submissions} />
</UnderlineTabs.Content>
@@ -35,6 +35,12 @@
import { currentOrgDomain, currentOrgPath, isFreePlan } from '$lib/utils/store/org';
import { page } from '$app/stores';
interface Props {
hasUnsavedChanges?: boolean;
}
let { hasUnsavedChanges = $bindable(false) }: Props = $props();
let isLoading = $state(false);
let isDeleting = $state(false);
let errors: {
@@ -45,7 +51,6 @@
description: undefined
});
let avatar: string | undefined;
let hasUnsavedChanges = $state(false);
let openDeleteModal = $state(false);
let selectedTagIds = $state<string[]>([]);
let initialTagIds = $state<string[]>([]);
+13 -13
View File
@@ -3,7 +3,14 @@ import type { Course } from '$features/course/types';
import type { CourseMembers, CourseSectionWithLessons, ListLessons } from '$features/course/utils/types';
import { writable, type Writable } from 'svelte/store';
export const defaultCourse = {
const defaultLessonTabsOrder: NonNullable<NonNullable<Course['metadata']>['lessonTabsOrder']> = [
{ id: 1, name: 'course.navItem.lessons.materials.tabs.note.title' },
{ id: 2, name: 'course.navItem.lessons.materials.tabs.slide.title' },
{ id: 3, name: 'course.navItem.lessons.materials.tabs.video.title' },
{ id: 4, name: 'course.navItem.lessons.materials.tabs.document.title' }
];
export const defaultCourse: Course = {
id: '',
title: '',
description: '',
@@ -12,8 +19,10 @@ export const defaultCourse = {
cost: 0,
currency: 'USD',
status: 'ACTIVE',
isCertificateDownloadable: false,
certificateTheme: 'professional',
certificate: {
isDownloadable: false,
theme: 'professional'
},
isPublished: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -24,10 +33,6 @@ export const defaultCourse = {
bannerImage: null,
attendance: [],
group: null,
course_section: [],
sections: [],
lessons: [],
exercises: [],
content: {
grouped: false,
sections: [],
@@ -52,12 +57,7 @@ export const defaultCourse = {
description: '',
imgUrl: ''
},
lessonTabsOrder: [
{ id: 1, name: 'course.navItem.lessons.materials.tabs.note.title' },
{ id: 2, name: 'course.navItem.lessons.materials.tabs.slide.title' },
{ id: 3, name: 'course.navItem.lessons.materials.tabs.video.title' },
{ id: 4, name: 'course.navItem.lessons.materials.tabs.document.title' }
],
lessonTabsOrder: defaultLessonTabsOrder,
grading: false,
lessonDownload: true,
allowNewStudent: false,
@@ -0,0 +1,16 @@
import { writable } from 'svelte/store';
export type CourseCompletionModalState = {
open: boolean;
courseId: string;
};
export const courseCompletionModal = writable<CourseCompletionModalState | null>(null);
export function openCourseCompletionModal(courseId: string) {
courseCompletionModal.set({ open: true, courseId });
}
export function closeCourseCompletionModal() {
courseCompletionModal.set(null);
}
@@ -0,0 +1,10 @@
import { CERTIFICATE_THEME_IDS, DEFAULT_CERTIFICATE_THEME_ID, type CertificateThemeId } from './constants';
const CERTIFICATE_THEME_ID_SET = new Set<string>(CERTIFICATE_THEME_IDS);
export function parseCertificateThemeId(raw: string | null | undefined): CertificateThemeId {
if (raw != null && raw !== '' && CERTIFICATE_THEME_ID_SET.has(raw)) {
return raw as CertificateThemeId;
}
return DEFAULT_CERTIFICATE_THEME_ID;
}
@@ -37,6 +37,19 @@ export type CourseSortOrder = (typeof CourseSortOrder)[keyof typeof CourseSortOr
export const DEFAULT_SORT_ORDER: CourseSortOrder = CourseSortOrder.Desc;
export const CERTIFICATE_THEME_IDS = [
'professional',
'plain',
'purpleProfessionalBadge',
'blueProfessionalBadge',
'purpleBadgePattern',
'blueBadgePattern'
] as const;
export type CertificateThemeId = (typeof CERTIFICATE_THEME_IDS)[number];
export const DEFAULT_CERTIFICATE_THEME_ID: CertificateThemeId = 'professional';
const COURSE_SORT_ORDER_VALUES = new Set<string>(Object.values(CourseSortOrder));
export function parseCourseSortOrder(value: string | null | undefined): CourseSortOrder {
@@ -71,16 +71,17 @@ export function formatAnswers(data: {
answers: SubmissionAnswer[];
}): Record<string, AnswerData> {
const answers: Record<string, AnswerData> = {};
const questionByIdAndName: Record<string, string> = {};
const questionKeyById: Record<string, string> = {};
for (const question of data.questions) {
questionByIdAndName[String(question.id)] = question.name ?? '';
const key = question.name ? String(question.name) : String(question.id);
questionKeyById[String(question.id)] = key;
}
for (const answer of data.answers) {
const questionName = questionByIdAndName[String(answer.questionId)];
if (questionName && answer.answerData) {
answers[questionName] = answer.answerData;
const questionKey = questionKeyById[String(answer.questionId)];
if (questionKey && answer.answerData) {
answers[questionKey] = answer.answerData;
}
}
@@ -266,6 +266,13 @@ export type DeleteCourseRequest = (typeof classroomio.course)[':courseId']['$del
export type UpdateCourseContentRequest = (typeof classroomio.course)[':courseId']['content']['$put'];
export type DeleteCourseContentRequest = (typeof classroomio.course)[':courseId']['content']['$delete'];
export type GetCourseProgressRequest = (typeof classroomio.course)[':courseId']['progress']['$get'];
export type GetCertificationEvaluationRequest =
(typeof classroomio.course)[':courseId']['certification-evaluation']['$get'];
export type GetCertificationEvaluationSuccess = Extract<
InferResponseType<GetCertificationEvaluationRequest>,
{ success: true }
>;
export type CertificationEvaluationData = GetCertificationEvaluationSuccess['data'];
export type CloneCourseRequest = (typeof classroomio.course)[':courseId']['clone']['$post'];
export type EnrollCourseRequest = (typeof classroomio.course)[':courseId']['enroll']['$post'];
@@ -280,12 +287,17 @@ export type CourseMetadata = (NonNullable<ApiCourse['metadata']> extends object
: Record<string, unknown>) & {
isContentGroupingEnabled?: boolean;
};
export type CourseCertificate =
NonNullable<ApiCourse['certificate']> extends object
? NonNullable<ApiCourse['certificate']>
: Record<string, unknown>;
export type CourseContent = NonNullable<ApiCourse['content']>;
export type CourseContentSection = CourseContent['sections'][number];
export type CourseContentItem = CourseContent['items'][number];
export type Course = Omit<ApiCourse, 'metadata' | 'lessons' | 'sections' | 'exercises'> & {
metadata?: CourseMetadata | null;
certificate?: CourseCertificate | null;
content: CourseContent;
};
@@ -44,7 +44,7 @@
<UnderlineTabs.Root bind:value={currentTab}>
<UnderlineTabs.List>
{#each tabs as tab}
{#each tabs as tab (tab.value)}
<UnderlineTabs.Trigger value={tab.value}>
{$t(tab.label)}
</UnderlineTabs.Trigger>
@@ -1,5 +1,6 @@
import type {
AssignAudienceCoursesRequest,
DeleteAudienceMemberRequest,
DeleteTeamRequest,
DomainRequestRequest,
GetAudienceRequest,
@@ -8,6 +9,8 @@ import type {
InviteTeamRequest,
OrgPublicCourses,
OrganizationAudience,
OrganizationAudiencePagination,
OrganizationAudienceQuery,
OrganizationTeamMembers,
ResendAudienceInviteRequest,
RevokeAudienceInviteRequest
@@ -36,6 +39,7 @@ import { resolve } from '$app/paths';
import { snackbar } from '$features/ui/snackbar/store';
import { t } from '$lib/utils/functions/translations';
import { uploadImage } from '$lib/utils/services/upload';
import { DEFAULT_ORG_AUDIENCE_QUERY, toAudienceRequestQuery } from '../utils/audience-query-utils';
export interface TOrgUpdateForm {
name?: string;
@@ -59,9 +63,16 @@ export interface TOrgUpdateForm {
class OrgApi extends BaseApiWithErrors {
teamMembers = $state<OrganizationTeamMembers>([]);
audience = $state<OrganizationAudience>([]);
audiencePagination = $state<OrganizationAudiencePagination | null>(null);
publicCourses: OrgPublicCourses = $state([]);
isFetchingOrgPublicCourses = $state(false);
private activeAudienceRequestController: AbortController | null = null;
cancelAudienceRequest() {
this.activeAudienceRequestController?.abort();
this.activeAudienceRequestController = null;
}
/**
* Gets organization team members (non-students)
@@ -91,16 +102,52 @@ class OrgApi extends BaseApiWithErrors {
* @param orgId Organization ID
* @returns Audience array
*/
async getOrgAudience(orgId?: string) {
async getOrgAudience(
orgId?: string,
query?: Partial<OrganizationAudienceQuery>,
options: { abortPrevious?: boolean; signal?: AbortSignal } = {}
) {
if (!orgId) return;
return this.execute<GetAudienceRequest>({
requestFn: () => classroomio.organization.audience.$get(),
const requestQuery = toAudienceRequestQuery({
...DEFAULT_ORG_AUDIENCE_QUERY,
...query
});
let requestSignal = options.signal;
let requestController: AbortController | null = null;
if (options.abortPrevious) {
this.cancelAudienceRequest();
requestController = new AbortController();
this.activeAudienceRequestController = requestController;
requestSignal = requestController.signal;
}
const response = await this.execute<GetAudienceRequest>({
requestFn: () =>
classroomio.organization.audience.$get(
{
query: requestQuery!
},
{
init: {
signal: requestSignal
}
}
),
logContext: 'fetching organization audience',
onSuccess: (response) => {
this.audience = response.data;
this.audiencePagination = response.pagination;
}
});
if (requestController && this.activeAudienceRequestController === requestController) {
this.activeAudienceRequestController = null;
}
return response;
}
/**
@@ -384,7 +431,6 @@ class OrgApi extends BaseApiWithErrors {
* Imports users into the organization as students
*/
async importAudienceMembers(data: TImportAudienceMembers) {
console.log('importAudienceMembers', data);
return this.execute<ImportAudienceRequest>({
requestFn: () =>
classroomio.organization.audience.import.$post({
@@ -396,7 +442,8 @@ class OrgApi extends BaseApiWithErrors {
snackbar.success(
t.get('audience.import.snackbar_success', {
imported: d.imported,
skipped: d.skipped,
assigned: d.assigned ?? 0,
pendingInvitesRenewed: d.pendingInvitesRenewed ?? 0,
emailsSent: d.emailsSent
})
);
@@ -460,8 +507,6 @@ class OrgApi extends BaseApiWithErrors {
} else {
snackbar.error('audience.invite.resend_snackbar_email_failed');
}
const orgId = get(currentOrg).id;
if (orgId) this.getOrgAudience(orgId);
},
onError: (result) => {
if (typeof result === 'string') {
@@ -480,8 +525,6 @@ class OrgApi extends BaseApiWithErrors {
logContext: 'revoking audience invite',
onSuccess: () => {
snackbar.success('audience.invite.revoke_snackbar_success');
const orgId = get(currentOrg).id;
if (orgId) this.getOrgAudience(orgId);
},
onError: (result) => {
if (typeof result === 'string') {
@@ -490,6 +533,27 @@ class OrgApi extends BaseApiWithErrors {
}
});
}
async deleteAudienceMember(memberId: number) {
return this.execute<DeleteAudienceMemberRequest>({
requestFn: () =>
classroomio.organization.audience[':memberId'].$delete({
param: { memberId: memberId.toString() }
}),
logContext: 'removing audience member',
onSuccess: () => {
snackbar.success('audience.delete.snackbar_success');
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error(result);
return;
}
snackbar.error('error' in result ? result.error : result.message);
}
});
}
}
export const orgApi = new OrgApi();
@@ -0,0 +1,58 @@
import type { OrganizationAudienceQuery, OrganizationAudienceRequestQuery } from './types';
export const DEFAULT_ORG_AUDIENCE_QUERY: OrganizationAudienceQuery = {
page: 1,
limit: 20,
sortBy: 'createdAt',
sortOrder: 'desc'
};
export function getAudienceQueryFromSearchParams(
searchParams: URLSearchParams,
defaults: OrganizationAudienceQuery = DEFAULT_ORG_AUDIENCE_QUERY
): OrganizationAudienceQuery {
const page = Number(searchParams.get('page'));
const limit = Number(searchParams.get('limit'));
const sortBy = searchParams.get('sortBy');
const sortOrder = searchParams.get('sortOrder');
const search = searchParams.get('search')?.trim() || undefined;
return {
page: Number.isFinite(page) && page > 0 ? Math.floor(page) : defaults.page,
limit: Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : defaults.limit,
sortBy: sortBy === 'name' || sortBy === 'email' || sortBy === 'createdAt' ? sortBy : defaults.sortBy,
sortOrder: sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : defaults.sortOrder,
search
};
}
export function getAudienceSearchParams(query: OrganizationAudienceQuery): URLSearchParams {
const searchParams = new URLSearchParams();
searchParams.set('page', String(query.page));
searchParams.set('limit', String(query.limit));
searchParams.set('sortBy', query.sortBy);
searchParams.set('sortOrder', query.sortOrder);
if (query.search) {
searchParams.set('search', query.search);
}
return searchParams;
}
export function toAudienceRequestQuery(
query?: Partial<OrganizationAudienceQuery>
): OrganizationAudienceRequestQuery | undefined {
if (!query) {
return undefined;
}
return {
page: query.page != null ? String(query.page) : undefined,
limit: query.limit != null ? String(query.limit) : undefined,
search: query.search,
sortBy: query.sortBy,
sortOrder: query.sortOrder
};
}
@@ -1,4 +1,4 @@
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
import { classroomio, type InferRequestType, type InferResponseType } from '$lib/utils/services/api';
// Team member types
export type GetTeamRequest = (typeof classroomio.organization)['team']['$get'];
@@ -20,9 +20,19 @@ export type OrganizationTeamMembers = Array<
export type GetAudienceRequest = (typeof classroomio.organization)['audience']['$get'];
export type OrganizationAudienceResponse = InferResponseType<GetAudienceRequest> | null;
export type OrganizationAudienceSuccess = Extract<InferResponseType<GetAudienceRequest>, { success: true }>;
export type OrganizationAudienceRequestQuery = NonNullable<InferRequestType<GetAudienceRequest>['query']>;
export type OrganizationAudienceSortBy = 'createdAt' | 'name' | 'email';
export type OrganizationAudienceSortOrder = 'asc' | 'desc';
export type OrganizationAudienceQuery = {
page: number;
limit: number;
search?: string;
sortBy: OrganizationAudienceSortBy;
sortOrder: OrganizationAudienceSortOrder;
};
export type OrganizationAudience = OrganizationAudienceSuccess['data'];
export type OrganizationAudiencePagination = OrganizationAudienceSuccess['pagination'];
export type OrganizationAudienceMember = OrganizationAudience[number];
// Org Public Courses types
@@ -43,6 +53,8 @@ export type InviteTeamData = InviteTeamSuccess['data'];
export type DeleteTeamRequest = (typeof classroomio.organization)['team'][':memberId']['$delete'];
export type DeleteTeamSuccess = Extract<InferResponseType<DeleteTeamRequest>, { success: true }>;
export type DeleteAudienceMemberRequest = (typeof classroomio.organization)['audience'][':memberId']['$delete'];
export type DeleteAudienceMemberSuccess = Extract<InferResponseType<DeleteAudienceMemberRequest>, { success: true }>;
// update organization types
export type UpdateOrganizationRequest = (typeof classroomio.organization)['$put'];
@@ -370,7 +370,7 @@
</Field.Field>
{:else if currentDomainStatus !== 'verified'}
<Field.Field>
<div class="rounded-md border px-4 py-3 text-sm ui:text-muted-foreground">
<div class="ui:text-muted-foreground rounded-md border px-4 py-3 text-sm">
{$t('components.settings.domains.reconnect_hint')}
</div>
</Field.Field>
@@ -10,3 +10,10 @@ export function toggleConfetti() {
show: !confetti.show
}));
}
export function setConfetti(show: boolean) {
confettiStore.update((confetti) => ({
...confetti,
show
}));
}
@@ -18,6 +18,7 @@
HoverableItem,
PreviewIcon
} from '@cio/ui/custom/moving-icons';
import type { TCourseUpdate } from '@cio/utils/validation/course';
import { IconButton } from '@cio/ui/custom/icon-button';
import { CloseButton } from '$features/ui';
@@ -151,15 +152,22 @@
course.slug = course.slug || generateSlug(course.title, { appendTimestamp: true });
console.log('course', course);
await courseApi.update(courseId, {
const updatePayload = {
...course,
type: course.type!,
slug: course.slug!,
isPublished: course.isPublished ?? undefined,
overview: course.overview ?? undefined,
isCertificateDownloadable: course.isCertificateDownloadable ?? undefined,
certificateTheme: course.certificateTheme ?? undefined
});
certificate: course.certificate
? {
...course.certificate,
isDownloadable: course.certificate.isDownloadable ?? undefined,
theme: course.certificate.theme ?? undefined
}
: undefined
} as TCourseUpdate;
await courseApi.update(courseId, updatePayload);
loading = false;
syncCourseStore(course);
@@ -173,7 +181,7 @@
});
}
function setter(value: any, setterKey: string) {
function setter(value: unknown, setterKey: string) {
if (typeof value === 'undefined') return;
// Untrack the course change to avoid unnecessary re-renders
@@ -231,7 +239,7 @@
</Sidebar.GroupLabel>
<Sidebar.GroupContent>
<Sidebar.Menu>
{#each sections as section}
{#each sections as section (section.key)}
{@const SectionIcon = section.icon}
<HoverableItem>
{#snippet children(isHovered)}
@@ -253,7 +261,7 @@
</Sidebar.Group>
{:else}
<Sidebar.Menu>
{#each sections as section}
{#each sections as section (section.key)}
{@const SectionIcon = section.icon}
<HoverableItem>
{#snippet children(isHovered)}
@@ -32,7 +32,8 @@ export const QUESTION_TYPES = QUESTION_TYPE_REGISTRY.filter((qt) => !HIDDEN_QUES
id: questionType.id,
key: questionType.key,
label: questionType.label,
labelKey: QUESTION_TYPE_LABEL_KEYS[questionType.key]
labelKey: QUESTION_TYPE_LABEL_KEYS[questionType.key],
autoGradable: questionType.autoGradable
})
);
@@ -21,17 +21,6 @@ function getSiteNameValidation() {
});
}
export const saveCertificateValidation = (fields = {}) => {
const schema = z.object({
description: z.string().max(200, 'course.navItem.certificates.description_error'),
is_certificate_downloadable: z.boolean(),
certificate_theme: z.string()
});
const { error } = schema.safeParse(fields);
return processErrors(error);
};
export const getConfirmPasswordError = ({ password, confirmPassword }) => {
return password > 6 && confirmPassword > 6 && password !== confirmPassword
? `${t.get('validations.confirm_password.not_match')}`
@@ -67,6 +67,10 @@ export class BaseApi {
return typedResult;
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return;
}
console.error(`Error in ${logContext}:`, error);
this.error = getErrMsg(error, `Error ${logContext}:`);
@@ -19,6 +19,39 @@ export const getRequestBaseUrl = () => {
return env.PUBLIC_SERVER_URL;
};
function mergeAbortSignals(...signals: Array<AbortSignal | null | undefined>) {
const activeSignals = signals.filter((signal): signal is AbortSignal => Boolean(signal));
if (activeSignals.length === 0) {
return undefined;
}
if (activeSignals.length === 1) {
return activeSignals[0];
}
const controller = new AbortController();
const abortWithSignal = (signal: AbortSignal) => {
if (controller.signal.aborted) {
return;
}
controller.abort(signal.reason);
};
for (const signal of activeSignals) {
if (signal.aborted) {
abortWithSignal(signal);
break;
}
signal.addEventListener('abort', () => abortWithSignal(signal), { once: true });
}
return controller.signal;
}
class ApiClient {
private config: Required<ApiClientConfig>;
@@ -70,14 +103,14 @@ class ApiClient {
}
// Create abort controller for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
const timeoutController = new AbortController();
const timeoutId = setTimeout(() => timeoutController.abort(), timeout);
const requestInit: RequestInit = {
...fetchConfig,
headers: isFormData ? undefined : headers,
body: requestBody,
signal: controller.signal
signal: mergeAbortSignals(fetchConfig.signal, timeoutController.signal)
};
let lastError: Error | null = null;
@@ -124,6 +157,10 @@ class ApiClient {
// Handle abort (timeout)
if (error instanceof Error && error.name === 'AbortError') {
if (!timeoutController.signal.aborted) {
throw error;
}
throw new ApiError('Request timeout', 408, 'Request Timeout');
}
@@ -1118,7 +1118,11 @@
"answers_required": "Angiv venligst mindst ét ​​svar, før du sender.",
"checking_submission": "Tjek din indsendelse...",
"answer_required": "Besvar venligst dette spørgsmål, før du fortsætter.",
"instructor_feedback": "Instruktør feedback"
"instructor_feedback": "Instruktør feedback",
"attempt_counter": "Forsøg {current} af {total}",
"previous_try": "Tidligere forsøg",
"next_try": "Næste forsøg",
"try_again": "Prøv igen"
},
"edit_mode": {
"delete_modal": "Slet øvelse",
@@ -1371,7 +1375,11 @@
"not_set": "Ikke indstillet",
"incorrect_badge": "Forkert"
}
}
},
"settings": "Indstillinger",
"settings_allow_multiple": "Tillad flere forsøg",
"settings_allow_multiple_helper": "Studerende kan indsende mere end én gang og gennemgå tidligere forsøg på resultatskærmen.",
"settings_save": "Gem indstillinger"
},
"new_exercise_modal": {
"heading": "Ny øvelse",
@@ -1632,18 +1640,29 @@
"later": "Luk"
}
},
"certificates": {
"progress_hint": "{current} % gennemført ({required} % kræves for at downloade)"
},
"certification": {
"rules_heading": "Certificeringsregler",
"rules_heading": "Regler",
"deadline_label": "Certificeringsfrist",
"deadline_helper": "Studerende skal opfylde fuldførelsesprocenten inden denne dato (lokal tid). Lad være tomt uden deadline.",
"threshold_label": "Minimum fuldførelse (%)",
"threshold_helper": "Kombineret fremskridt i lektion og træning kræves for at opnå certifikatet (0-100). Standard er 100 %.",
"email_message_label": "E-mail med certificering",
"email_message_helper": "Valgfri besked vist i e-mailen, når en elev opfylder kravene. Et link til certifikatsiden er altid inkluderet.",
"final_exercise_label": "Afsluttende øvelse (valgfrit)",
"final_exercise_none": "ingen",
"final_exercise_helper": "Når den er indstillet, skal eleverne mindst opnå minimumscore på denne øvelse (bedste forsøg tæller) ud over fuldførelsesprocenten ovenfor.",
"min_exercise_score_label": "Minimumsscore på sidste øvelse (%)",
"min_exercise_score_helper": "Score er en procentdel af det samlede antal point, der er mulige på den øvelse (0-100).",
"blocker_progress": "Dit kursus fremskridt er {current}%; {required} % er påkrævet.",
"blocker_deadline": "Certificeringsfristen er udløbet.",
"blocker_no_content": "Dette kursus har ingen lektioner eller øvelser at gennemføre.",
"blocker_final_not_submitted": "Indsend den nødvendige øvelse \"{exerciseTitle}\" for at kvalificere dig til certifikatet.",
"blocker_final_pending": "Dit forsøg på \"{exerciseTitle}\" bliver stadig bedømt.",
"blocker_final_score": "Din bedste score på \"{exerciseTitle}\" er {bestPercent}%; {requiredPercent} % er påkrævet.",
"blocker_final_misconfigured": "Certificeringsøvelsen \"{exerciseTitle}\" har ingen point konfigureret; bed din instruktør om at ordne det.",
"email_message_label": "E-mail besked",
"email_message_placeholder": "f.eks. Vi er stolte af dine fremskridt - fortsæt med at lære!"
},
"certificates": {
"progress_hint": "{current} % gennemført ({required} % kræves for at downloade)"
}
},
"courses": {
@@ -1118,7 +1118,11 @@
"answers_required": "Bitte geben Sie vor dem Absenden mindestens eine Antwort an.",
"checking_submission": "Ich prüfe Ihre Einreichung...",
"answer_required": "Bitte beantworten Sie diese Frage, bevor Sie fortfahren.",
"instructor_feedback": "Feedback des Dozenten"
"instructor_feedback": "Feedback des Dozenten",
"attempt_counter": "Versuch {current} von {total}",
"previous_try": "Vorheriger Versuch",
"next_try": "Nächster Versuch",
"try_again": "Versuchen Sie es erneut"
},
"edit_mode": {
"delete_modal": "Übung löschen",
@@ -1371,7 +1375,11 @@
"not_set": "Nicht festgelegt",
"incorrect_badge": "Falsch"
}
}
},
"settings": "Einstellungen",
"settings_allow_multiple": "Erlauben Sie mehrere Versuche",
"settings_allow_multiple_helper": "Studierende können mehr als einmal einreichen und frühere Versuche auf dem Ergebnisbildschirm überprüfen.",
"settings_save": "Einstellungen speichern"
},
"new_exercise_modal": {
"heading": "Neue Übung",
@@ -1632,18 +1640,29 @@
"later": "Schließen"
}
},
"certificates": {
"progress_hint": "{current} % abgeschlossen ({required} % zum Herunterladen erforderlich)"
},
"certification": {
"rules_heading": "Zertifizierungsregeln",
"rules_heading": "Regeln",
"deadline_label": "Zertifizierungsfrist",
"deadline_helper": "Die Schüler müssen den Abschlussprozentsatz vor diesem Datum/Uhrzeit (Ortszeit) erreichen. Für keine Frist leer lassen.",
"threshold_label": "Mindestabschluss (%)",
"threshold_helper": "Kombinierter Unterrichts- und Übungsfortschritt, der zum Erwerb des Zertifikats erforderlich ist (0–100). Der Standardwert ist 100 %.",
"email_message_label": "E-Mail-Nachricht zur Zertifizierung",
"email_message_helper": "Optionale Nachricht, die in der E-Mail angezeigt wird, wenn ein Student die Anforderungen erfüllt. Ein Link zur Zertifikatsseite ist immer enthalten.",
"final_exercise_label": "Abschlussübung (optional)",
"final_exercise_none": "keine",
"final_exercise_helper": "Wenn festgelegt, müssen die Schüler zusätzlich zu der oben genannten Abschlussquote mindestens die Mindestpunktzahl für diese Übung erreichen (bester Versuch zählt).",
"min_exercise_score_label": "Mindestpunktzahl bei der Abschlussübung (%)",
"min_exercise_score_helper": "Die Punktzahl ist ein Prozentsatz der bei dieser Übung möglichen Gesamtpunktzahl (0–100).",
"blocker_progress": "Ihr Kursfortschritt beträgt {current}%; {required}% ist erforderlich.",
"blocker_deadline": "Die Zertifizierungsfrist ist abgelaufen.",
"blocker_no_content": "Für diesen Kurs sind keine Lektionen oder Übungen erforderlich.",
"blocker_final_not_submitted": "Reichen Sie die erforderliche Übung „{exerciseTitle}“ ein, um sich für das Zertifikat zu qualifizieren.",
"blocker_final_pending": "Ihr Versuch bei „{exerciseTitle}“ wird noch bewertet.",
"blocker_final_score": "Ihre beste Punktzahl bei „{exerciseTitle}“ beträgt {bestPercent} %; {requiredPercent}% ist erforderlich.",
"blocker_final_misconfigured": "Für die Zertifizierungsübung „{exerciseTitle}“ sind keine Punkte konfiguriert; Bitten Sie Ihren Lehrer, das Problem zu beheben.",
"email_message_label": "E-Mail-Nachricht",
"email_message_placeholder": "z.B. Wir sind stolz auf Ihre Fortschritte – lernen Sie weiter!"
},
"certificates": {
"progress_hint": "{current} % abgeschlossen ({required} % zum Herunterladen erforderlich)"
}
},
"courses": {
@@ -978,13 +978,13 @@
"progress_hint": "{current}% complete ({required}% required to download)"
},
"certification": {
"rules_heading": "Certification rules",
"rules_heading": "Rules",
"deadline_label": "Certification deadline",
"deadline_helper": "Students must meet the completion percentage before this datetime (local time). Leave empty for no deadline.",
"threshold_label": "Minimum completion (%)",
"threshold_helper": "Combined lesson and exercise progress required to earn the certificate (0–100). Default is 100%.",
"final_exercise_label": "Final exercise (optional)",
"final_exercise_none": "None — no extra exercise score",
"final_exercise_none": "none",
"final_exercise_helper": "When set, students must reach at least the minimum score on this exercise (best attempt counts) in addition to the completion percentage above.",
"min_exercise_score_label": "Minimum score on final exercise (%)",
"min_exercise_score_helper": "Score is a percentage of the total points possible on that exercise (0–100).",
@@ -995,8 +995,7 @@
"blocker_final_pending": "Your attempt on \"{exerciseTitle}\" is still being graded.",
"blocker_final_score": "Your best score on \"{exerciseTitle}\" is {bestPercent}%; {requiredPercent}% is required.",
"blocker_final_misconfigured": "The certification exercise \"{exerciseTitle}\" has no points configured; ask your instructor to fix it.",
"email_message_label": "Certification email message",
"email_message_helper": "Optional message shown in the email when a student meets the requirements. A link to the certificate page is always included.",
"email_message_label": "Email Message",
"email_message_placeholder": "e.g. We are proud of your progress — keep learning!"
},
"completion": {
@@ -1118,7 +1118,11 @@
"answers_required": "Proporcione al menos una respuesta antes de enviar.",
"checking_submission": "Comprobando tu envío...",
"answer_required": "Por favor responda esta pregunta antes de continuar.",
"instructor_feedback": "Comentarios del instructor"
"instructor_feedback": "Comentarios del instructor",
"attempt_counter": "Intento {actual} de {total}",
"previous_try": "Intento anterior",
"next_try": "Próximo intento",
"try_again": "Inténtalo de nuevo"
},
"edit_mode": {
"delete_modal": "Eliminar ejercicio",
@@ -1371,7 +1375,11 @@
"not_set": "No establecido",
"incorrect_badge": "Incorrecto"
}
}
},
"settings": "Configuración",
"settings_allow_multiple": "Permitir múltiples intentos",
"settings_allow_multiple_helper": "Los estudiantes pueden enviar más de una vez y revisar intentos anteriores en la pantalla de resultados.",
"settings_save": "Guardar configuración"
},
"new_exercise_modal": {
"heading": "Nuevo ejercicio",
@@ -1632,18 +1640,29 @@
"later": "Cerrar"
}
},
"certificates": {
"progress_hint": "{current}% completado ({required}% requerido para descargar)"
},
"certification": {
"rules_heading": "Reglas de certificación",
"rules_heading": "Reglas",
"deadline_label": "Fecha límite de certificación",
"deadline_helper": "Los estudiantes deben alcanzar el porcentaje de finalización antes de esta fecha (hora local). Déjelo vacío sin fecha límite.",
"threshold_label": "Finalización mínima (%)",
"threshold_helper": "Progreso combinado de lección y ejercicio requerido para obtener el certificado (0–100). El valor predeterminado es 100%.",
"email_message_label": "Mensaje de correo electrónico de certificación",
"email_message_helper": "Mensaje opcional que se muestra en el correo electrónico cuando un estudiante cumple con los requisitos. Siempre se incluye un enlace a la página del certificado.",
"email_message_placeholder": "p.ej. Estamos orgullosos de tu progreso: ¡sigue aprendiendo!"
},
"certificates": {
"progress_hint": "{current}% completado ({required}% requerido para descargar)"
"final_exercise_label": "Ejercicio final (opcional)",
"final_exercise_none": "ninguno",
"final_exercise_helper": "Cuando se establece, los estudiantes deben alcanzar al menos la puntuación mínima en este ejercicio (el mejor intento cuenta) además del porcentaje de finalización anterior.",
"min_exercise_score_label": "Puntuación mínima en el ejercicio final (%)",
"min_exercise_score_helper": "La puntuación es un porcentaje del total de puntos posibles en ese ejercicio (0-100).",
"blocker_progress": "El progreso de su curso es {current}%; {required}% es obligatorio.",
"blocker_deadline": "La fecha límite de certificación ha pasado.",
"blocker_no_content": "Este curso no tiene lecciones ni ejercicios para completar.",
"blocker_final_not_submitted": "Envíe el ejercicio requerido \"{exerciseTitle}\" para calificar para el certificado.",
"blocker_final_pending": "Su intento en \"{exerciseTitle}\" aún se está calificando.",
"blocker_final_score": "Su mejor puntuación en \"{exerciseTitle}\" es {bestPercent}%; {requiredPercent}% es obligatorio.",
"blocker_final_misconfigured": "El ejercicio de certificación \"{exerciseTitle}\" no tiene puntos configurados; Pídele a tu instructor que lo arregle.",
"email_message_label": "Mensaje de correo electrónico",
"email_message_placeholder": "por ej. Estamos orgullosos de tu progreso: ¡sigue aprendiendo!"
}
},
"courses": {
@@ -1118,7 +1118,11 @@
"answers_required": "Veuillez fournir au moins une réponse avant de soumettre.",
"checking_submission": "Vérification de votre soumission...",
"answer_required": "Veuillez répondre à cette question avant de continuer.",
"instructor_feedback": "Commentaires de l'instructeur"
"instructor_feedback": "Commentaires de l'instructeur",
"attempt_counter": "Tentative {actuelle} de {total}",
"previous_try": "Tentative précédente",
"next_try": "Prochaine tentative",
"try_again": "Réessayez"
},
"edit_mode": {
"delete_modal": "Supprimer l'exercice",
@@ -1371,7 +1375,11 @@
"not_set": "Non défini",
"incorrect_badge": "Incorrect"
}
}
},
"settings": "Paramètres",
"settings_allow_multiple": "Autoriser plusieurs tentatives",
"settings_allow_multiple_helper": "Les étudiants peuvent soumettre plus d'une fois et revoir leurs tentatives antérieures sur l'écran des résultats.",
"settings_save": "Enregistrer les paramètres"
},
"new_exercise_modal": {
"heading": "Nouvel exercice",
@@ -1632,18 +1640,29 @@
"later": "Fermer"
}
},
"certificates": {
"progress_hint": "{current} % terminé ({required} % requis pour le téléchargement)"
},
"certification": {
"rules_heading": "Règles de certification",
"rules_heading": "Règles",
"deadline_label": "Date limite de certification",
"deadline_helper": "Les étudiants doivent atteindre le pourcentage d'achèvement avant cette date/heure (heure locale). Laisser vide sans date limite.",
"threshold_label": "Achèvement minimum (%)",
"threshold_helper": "Progrès combiné des leçons et des exercices requis pour obtenir le certificat (0-100). La valeur par défaut est 100 %.",
"email_message_label": "Courriel de certification",
"email_message_helper": "Message facultatif affiché dans l'e-mail lorsqu'un étudiant répond aux exigences. Un lien vers la page du certificat est toujours inclus.",
"final_exercise_label": "Exercice final (facultatif)",
"final_exercise_none": "aucun",
"final_exercise_helper": "Une fois défini, les élèves doivent atteindre au moins le score minimum pour cet exercice (la meilleure tentative compte) en plus du pourcentage d'achèvement ci-dessus.",
"min_exercise_score_label": "Note minimale à l'exercice final (%)",
"min_exercise_score_helper": "Le score est un pourcentage du total de points possibles sur cet exercice (0 à 100).",
"blocker_progress": "La progression de votre cours est de {current} % ; {obligatoire} % est requis.",
"blocker_deadline": "La date limite de certification est dépassée.",
"blocker_no_content": "Ce cours ne comporte aucune leçon ni exercice à réaliser.",
"blocker_final_not_submitted": "Soumettez l'exercice requis \"{exerciseTitle}\" pour être admissible au certificat.",
"blocker_final_pending": "Votre tentative sur \"{exerciseTitle}\" est toujours en cours d'évaluation.",
"blocker_final_score": "Votre meilleur score sur \"{exerciseTitle}\" est de {bestPercent} % ; {requiredPercent} % est requis.",
"blocker_final_misconfigured": "L'exercice de certification « {exerciseTitle} » n'a aucun point configuré ; demandez à votre instructeur de le réparer.",
"email_message_label": "Message électronique",
"email_message_placeholder": "par ex. Nous sommes fiers de vos progrès – continuez à apprendre !"
},
"certificates": {
"progress_hint": "{current} % terminé ({required} % requis pour le téléchargement)"
}
},
"courses": {
@@ -1118,7 +1118,11 @@
"answers_required": "कृपया सबमिट करने से पहले कम से कम एक उत्तर प्रदान करें।",
"checking_submission": "आपके सबमिशन की जाँच की जा रही है...",
"answer_required": "कृपया आगे बढ़ने से पहले इस प्रश्न का उत्तर दें।",
"instructor_feedback": "प्रशिक्षक प्रतिक्रिया"
"instructor_feedback": "प्रशिक्षक प्रतिक्रिया",
"attempt_counter": "{कुल} का {वर्तमान} प्रयास करें",
"previous_try": "पिछला प्रयास",
"next_try": "अगला प्रयास",
"try_again": "पुनः प्रयास करें"
},
"edit_mode": {
"delete_modal": "व्यायाम हटाएँ",
@@ -1371,7 +1375,11 @@
"not_set": "सेट नहीं",
"incorrect_badge": "गलत"
}
}
},
"settings": "सेटिंग्स",
"settings_allow_multiple": "एकाधिक प्रयासों की अनुमति दें",
"settings_allow_multiple_helper": "छात्र एक से अधिक बार सबमिट कर सकते हैं और परिणाम स्क्रीन पर पिछले प्रयासों की समीक्षा कर सकते हैं।",
"settings_save": "सेटिंग्स सहेजें"
},
"new_exercise_modal": {
"heading": "नया व्यायाम",
@@ -1632,18 +1640,29 @@
"later": "बंद करें"
}
},
"certificates": {
"progress_hint": "{वर्तमान}% पूर्ण (डाउनलोड करने के लिए {आवश्यक}% आवश्यक)"
},
"certification": {
"rules_heading": "प्रमाणन नियम",
"rules_heading": "नियम",
"deadline_label": "प्रमाणीकरण की समय सीमा",
"deadline_helper": "छात्रों को इस दिनांक समय (स्थानीय समय) से पहले पूर्णता प्रतिशत पूरा करना होगा। बिना किसी समय सीमा के खाली छोड़ दें.",
"threshold_label": "न्यूनतम पूर्णता (%)",
"threshold_helper": "प्रमाणपत्र (0-100) अर्जित करने के लिए संयुक्त पाठ और अभ्यास प्रगति की आवश्यकता है। डिफ़ॉल्ट 100% है.",
"email_message_label": "प्रमाणीकरण ईमेल संदेश",
"email_message_helper": "जब कोई छात्र आवश्यकताओं को पूरा करता है तो ईमेल में वैकल्पिक संदेश दिखाया जाता है। प्रमाणपत्र पृष्ठ का एक लिंक हमेशा शामिल किया जाता है।",
"final_exercise_label": "अंतिम अभ्यास (वैकल्पिक)",
"final_exercise_none": "कोई नहीं",
"final_exercise_helper": "निर्धारित होने पर, छात्रों को उपरोक्त पूर्णता प्रतिशत के अलावा इस अभ्यास पर कम से कम न्यूनतम अंक (सर्वोत्तम प्रयास मायने रखता है) तक पहुंचना होगा।",
"min_exercise_score_label": "अंतिम अभ्यास पर न्यूनतम स्कोर (%)",
"min_exercise_score_helper": "स्कोर उस अभ्यास पर संभावित कुल अंकों का एक प्रतिशत है (0-100)।",
"blocker_progress": "आपके पाठ्यक्रम की प्रगति {वर्तमान}% है; {आवश्यक}% आवश्यक है.",
"blocker_deadline": "प्रमाणन की समय सीमा बीत चुकी है.",
"blocker_no_content": "इस पाठ्यक्रम में पूरा करने के लिए कोई पाठ या अभ्यास नहीं है।",
"blocker_final_not_submitted": "प्रमाणपत्र के लिए अर्हता प्राप्त करने के लिए आवश्यक अभ्यास \"{exerciseTitle}\" सबमिट करें।",
"blocker_final_pending": "\"{exerciseTitle}\" पर आपका प्रयास अभी भी वर्गीकृत किया जा रहा है।",
"blocker_final_score": "\"{exerciseTitle}\" पर आपका सर्वोत्तम स्कोर {bestPercent}% है; {requiredPercent}% आवश्यक है.",
"blocker_final_misconfigured": "प्रमाणन अभ्यास \"{exerciseTitle}\" में कोई बिंदु कॉन्फ़िगर नहीं किया गया है; अपने प्रशिक्षक से इसे ठीक करने के लिए कहें।",
"email_message_label": "ईमेल संदेश",
"email_message_placeholder": "जैसे हमें आपकी प्रगति पर गर्व है - सीखते रहें!"
},
"certificates": {
"progress_hint": "{वर्तमान}% पूर्ण (डाउनलोड करने के लिए {आवश्यक}% आवश्यक)"
}
},
"courses": {
@@ -1118,7 +1118,11 @@
"answers_required": "Przed przesłaniem proszę podać co najmniej jedną odpowiedź.",
"checking_submission": "Sprawdzam Twoje zgłoszenie...",
"answer_required": "Zanim przejdziesz dalej, odpowiedz na to pytanie.",
"instructor_feedback": "Informacje zwrotne od instruktora"
"instructor_feedback": "Informacje zwrotne od instruktora",
"attempt_counter": "Próba {bieżąca} z {total}",
"previous_try": "Poprzednia próba",
"next_try": "Następna próba",
"try_again": "Spróbuj ponownie"
},
"edit_mode": {
"delete_modal": "Usuń ćwiczenie",
@@ -1371,7 +1375,11 @@
"not_set": "Nie ustawiono",
"incorrect_badge": "Niepoprawne"
}
}
},
"settings": "Ustawienia",
"settings_allow_multiple": "Zezwalaj na wiele prób",
"settings_allow_multiple_helper": "Uczniowie mogą przesłać więcej niż raz i przejrzeć wcześniejsze próby na ekranie wyników.",
"settings_save": "Zapisz ustawienia"
},
"new_exercise_modal": {
"heading": "Nowe ćwiczenie",
@@ -1632,18 +1640,29 @@
"later": "Zamknij"
}
},
"certificates": {
"progress_hint": "{bieżący}% ukończenia ({required}% wymagane do pobrania)"
},
"certification": {
"rules_heading": "Zasady certyfikacji",
"rules_heading": "Zasady",
"deadline_label": "Termin certyfikacji",
"deadline_helper": "Studenci muszą osiągnąć procent ukończenia przed tą datą (czasu lokalnego). Pozostaw puste bez terminu.",
"threshold_label": "Minimalne ukończenie (%)",
"threshold_helper": "Do uzyskania certyfikatu wymagany jest łączny postęp w lekcjach i ćwiczeniach (0–100). Wartość domyślna to 100%.",
"email_message_label": "Wiadomość e-mail z certyfikatem",
"email_message_helper": "Opcjonalna wiadomość wyświetlana w wiadomości e-mail, gdy uczeń spełni wymagania. Zawsze dołączany jest link do strony z certyfikatem.",
"final_exercise_label": "Ćwiczenie końcowe (opcjonalnie)",
"final_exercise_none": "żaden",
"final_exercise_helper": "Jeśli ta opcja jest ustawiona, uczniowie muszą osiągnąć co najmniej minimalny wynik w tym ćwiczeniu (liczą się najlepsze próby) oprócz powyższego procentu ukończenia.",
"min_exercise_score_label": "Minimalny wynik w ćwiczeniu końcowym (%)",
"min_exercise_score_helper": "Wynik to procent wszystkich punktów możliwych do uzyskania w tym ćwiczeniu (0–100).",
"blocker_progress": "Twój postęp w kursie wynosi {current}%; Wymagany jest {required}%.",
"blocker_deadline": "Termin certyfikacji minął.",
"blocker_no_content": "Ten kurs nie zawiera żadnych lekcji ani ćwiczeń do ukończenia.",
"blocker_final_not_submitted": "Wykonaj wymagane ćwiczenie „{exerciseTitle}”, aby zakwalifikować się do certyfikatu.",
"blocker_final_pending": "Twoje podejście do zadania „{exerciseTitle}” jest nadal oceniane.",
"blocker_final_score": "Twój najlepszy wynik w „{exerciseTitle}” to {bestPercent}%; Wymagany jest {requiredPercent}%.",
"blocker_final_misconfigured": "W ćwiczeniu certyfikacyjnym „{exerciseTitle}” nie skonfigurowano żadnych punktów; poproś instruktora, żeby to naprawił.",
"email_message_label": "Wiadomość e-mail",
"email_message_placeholder": "np. Jesteśmy dumni z Twoich postępów — ucz się dalej!"
},
"certificates": {
"progress_hint": "{bieżący}% ukończenia ({required}% wymagane do pobrania)"
}
},
"courses": {
@@ -1118,7 +1118,11 @@
"answers_required": "Forneça pelo menos uma resposta antes de enviar.",
"checking_submission": "Verificando seu envio...",
"answer_required": "Por favor, responda a esta pergunta antes de prosseguir.",
"instructor_feedback": "Feedback do instrutor"
"instructor_feedback": "Feedback do instrutor",
"attempt_counter": "Tentativa {atual} de {total}",
"previous_try": "Tentativa anterior",
"next_try": "Próxima tentativa",
"try_again": "Tente novamente"
},
"edit_mode": {
"delete_modal": "Excluir exercício",
@@ -1371,7 +1375,11 @@
"not_set": "Não definido",
"incorrect_badge": "Incorreto"
}
}
},
"settings": "Configurações",
"settings_allow_multiple": "Permitir múltiplas tentativas",
"settings_allow_multiple_helper": "Os alunos podem enviar mais de uma vez e revisar as tentativas anteriores na tela de resultados.",
"settings_save": "Salvar configurações"
},
"new_exercise_modal": {
"heading": "Novo exercício",
@@ -1632,18 +1640,29 @@
"later": "Fechar"
}
},
"certificates": {
"progress_hint": "{current}% concluído ({required}% necessário para download)"
},
"certification": {
"rules_heading": "Regras de certificação",
"rules_heading": "Regras",
"deadline_label": "Prazo de certificação",
"deadline_helper": "Os alunos devem atingir o percentual de conclusão antes desta data e hora (horário local). Deixe em branco para não ter prazo.",
"threshold_label": "Conclusão mínima (%)",
"threshold_helper": "Progresso combinado de aulas e exercícios necessário para obter o certificado (0–100). O padrão é 100%.",
"email_message_label": "Mensagem de e-mail de certificação",
"email_message_helper": "Mensagem opcional mostrada no e-mail quando um aluno atende aos requisitos. Um link para a página do certificado está sempre incluído.",
"final_exercise_label": "Exercício final (opcional)",
"final_exercise_none": "nenhum",
"final_exercise_helper": "Quando definido, os alunos devem atingir pelo menos a pontuação mínima neste exercício (melhores tentativas contam), além do percentual de conclusão acima.",
"min_exercise_score_label": "Nota mínima no exercício final (%)",
"min_exercise_score_helper": "A pontuação é uma porcentagem do total de pontos possíveis naquele exercício (0–100).",
"blocker_progress": "O progresso do seu curso é {current}%; {obrigatório}% é obrigatório.",
"blocker_deadline": "O prazo de certificação expirou.",
"blocker_no_content": "Este curso não possui lições ou exercícios para serem concluídos.",
"blocker_final_not_submitted": "Envie o exercício obrigatório \"{exerciseTitle}\" para se qualificar para o certificado.",
"blocker_final_pending": "Sua tentativa em \"{exerciseTitle}\" ainda está sendo avaliada.",
"blocker_final_score": "Sua melhor pontuação em \"{exerciseTitle}\" é {bestPercent}%; {requiredPercent}% é obrigatório.",
"blocker_final_misconfigured": "O exercício de certificação \"{exerciseTitle}\" não possui pontos configurados; peça ao seu instrutor para consertar.",
"email_message_label": "Mensagem de e-mail",
"email_message_placeholder": "por exemplo Estamos orgulhosos do seu progresso – continue aprendendo!"
},
"certificates": {
"progress_hint": "{current}% concluído ({required}% necessário para download)"
}
},
"courses": {
@@ -1118,7 +1118,11 @@
"answers_required": "Перед отправкой дайте хотя бы один ответ.",
"checking_submission": "Проверка вашей заявки...",
"answer_required": "Пожалуйста, ответьте на этот вопрос, прежде чем продолжить.",
"instructor_feedback": "Отзывы инструктора"
"instructor_feedback": "Отзывы инструктора",
"attempt_counter": "Попытка {текущая} из {total}",
"previous_try": "Предыдущая попытка",
"next_try": "Следующая попытка",
"try_again": "Попробуйте еще раз"
},
"edit_mode": {
"delete_modal": "Удалить упражнение",
@@ -1371,7 +1375,11 @@
"not_set": "Не установлено",
"incorrect_badge": "Неправильный"
}
}
},
"settings": "Настройки",
"settings_allow_multiple": "Разрешить несколько попыток",
"settings_allow_multiple_helper": "Учащиеся могут отправлять заявки несколько раз и просматривать предыдущие попытки на экране результатов.",
"settings_save": "Сохранить настройки"
},
"new_exercise_modal": {
"heading": "Новое упражнение",
@@ -1632,18 +1640,29 @@
"later": "Закрыть"
}
},
"certificates": {
"progress_hint": "Выполнено {current}% (для загрузки требуется {required}%)"
},
"certification": {
"rules_heading": "Правила сертификации",
"rules_heading": "Правила",
"deadline_label": "Срок сертификации",
"deadline_helper": "Учащиеся должны достичь процента завершения до этой даты и времени (по местному времени). Оставьте пустым, если срок не установлен.",
"threshold_label": "Минимальное завершение (%)",
"threshold_helper": "Для получения сертификата необходим совмещенный прогресс урока и упражнений (0–100). По умолчанию — 100%.",
"email_message_label": "Сертификационное письмо по электронной почте",
"email_message_helper": "Дополнительное сообщение, отображаемое в электронном письме, когда учащийся соответствует требованиям. Ссылка на страницу сертификата всегда включена.",
"final_exercise_label": "Заключительное упражнение (по желанию)",
"final_exercise_none": "нет",
"final_exercise_helper": "Если этот параметр установлен, учащиеся должны набрать хотя бы минимальный балл за это упражнение (засчитывается лучшая попытка) в дополнение к указанному выше проценту выполнения.",
"min_exercise_score_label": "Минимальный балл за финальное упражнение (%)",
"min_exercise_score_helper": "Оценка представляет собой процент от общего количества баллов, возможных за это упражнение (0–100).",
"blocker_progress": "Ваш прогресс по курсу составляет {current}%; Требуется {required}%.",
"blocker_deadline": "Срок сертификации прошел.",
"blocker_no_content": "В этом курсе нет уроков или упражнений, которые необходимо выполнить.",
"blocker_final_not_submitted": "Чтобы получить сертификат, отправьте необходимое упражнение «{exercisionTitle}».",
"blocker_final_pending": "Ваша попытка выполнения \"{exercisionTitle}\" все еще оценивается.",
"blocker_final_score": "Ваш лучший результат в «{exercisionTitle}» — {bestPercent}%; Требуется {requiredPercent}%.",
"blocker_final_misconfigured": "Для сертификационного упражнения «{exercisionTitle}» не настроены баллы; попросите инструктора это исправить.",
"email_message_label": "Сообщение электронной почты",
"email_message_placeholder": "например Мы гордимся вашим прогрессом — продолжайте учиться!"
},
"certificates": {
"progress_hint": "Выполнено {current}% (для загрузки требуется {required}%)"
}
},
"courses": {
@@ -1118,7 +1118,11 @@
"answers_required": "Vui lòng cung cấp ít nhất một câu trả lời trước khi gửi.",
"checking_submission": "Đang kiểm tra nội dung gửi của bạn...",
"answer_required": "Vui lòng trả lời câu hỏi này trước khi tiếp tục.",
"instructor_feedback": "Phản hồi của giảng viên"
"instructor_feedback": "Phản hồi của giảng viên",
"attempt_counter": "Hãy thử {current} trong tổng số {total}",
"previous_try": "Lần thử trước",
"next_try": "Lần thử tiếp theo",
"try_again": "Thử lại"
},
"edit_mode": {
"delete_modal": "Xóa bài tập",
@@ -1371,7 +1375,11 @@
"not_set": "Chưa đặt",
"incorrect_badge": "sai"
}
}
},
"settings": "Cài đặt",
"settings_allow_multiple": "Cho phép thử nhiều lần",
"settings_allow_multiple_helper": "Học sinh có thể gửi nhiều lần và xem lại các lần thử trước đây trên màn hình kết quả.",
"settings_save": "Lưu cài đặt"
},
"new_exercise_modal": {
"heading": "Bài tập mới",
@@ -1632,18 +1640,29 @@
"later": "Đóng"
}
},
"certificates": {
"progress_hint": "Đã hoàn thành {current}% (cần có {required}% để tải xuống)"
},
"certification": {
"rules_heading": "Quy tắc chứng nhận",
"rules_heading": "Quy tắc",
"deadline_label": "Thời hạn chứng nhận",
"deadline_helper": "Học sinh phải đáp ứng tỷ lệ hoàn thành trước ngày này (giờ địa phương). Để trống không có thời hạn.",
"threshold_label": "Hoàn thành tối thiểu (%)",
"threshold_helper": "Cần có tiến độ kết hợp của bài học và bài tập để đạt được chứng chỉ (0–100). Mặc định là 100%.",
"email_message_label": "Thông báo email chứng nhận",
"email_message_helper": "Thông báo tùy chọn hiển thị trong email khi học sinh đáp ứng yêu cầu. Một liên kết đến trang chứng chỉ luôn được bao gồm.",
"email_message_placeholder": "ví dụ. Chúng tôi tự hào về sự tiến bộ của bạn - hãy tiếp tục học hỏi!"
},
"certificates": {
"progress_hint": "Đã hoàn thành {current}% (cần có {required}% để tải xuống)"
"final_exercise_label": "Bài tập cuối cùng (tùy chọn)",
"final_exercise_none": "không có",
"final_exercise_helper": "Khi được đặt ra, học sinh phải đạt ít nhất số điểm tối thiểu trong bài tập này (tính số lần thử tốt nhất) bên cạnh tỷ lệ hoàn thành ở trên.",
"min_exercise_score_label": "Điểm tối thiểu trong bài tập cuối cùng (%)",
"min_exercise_score_helper": "Điểm là tỷ lệ phần trăm của tổng số điểm có thể đạt được trong bài tập đó (0–100).",
"blocker_progress": "Tiến độ khóa học của bạn là {current}%; {required}% là bắt buộc.",
"blocker_deadline": "Thời hạn chứng nhận đã qua.",
"blocker_no_content": "Khóa học này không có bài học hoặc bài tập nào để hoàn thành.",
"blocker_final_not_submitted": "Gửi bài tập bắt buộc \"{exerciseTitle}\" để đủ điều kiện nhận chứng chỉ.",
"blocker_final_pending": "Nỗ lực của bạn về \"{exerciseTitle}\" vẫn đang được chấm điểm.",
"blocker_final_score": "Điểm cao nhất của bạn trong \"{exerciseTitle}\" là {bestPercent}%; {requiredPercent}% là bắt buộc.",
"blocker_final_misconfigured": "Bài tập chứng nhận \"{exerciseTitle}\" không có cấu hình điểm nào; nhờ người hướng dẫn khắc phục.",
"email_message_label": "Tin nhắn email",
"email_message_placeholder": "ví dụ: Chúng tôi tự hào về sự tiến bộ của bạn - hãy tiếp tục học hỏi!"
}
},
"courses": {
+2 -1
View File
@@ -6,7 +6,8 @@
const isNotFound = $derived(page.status === 404);
console.error('error message', page.error?.message);
console.error('Error message:', page.error?.message);
console.error('Error page:', page.url);
function goHome() {
window.location.href = '/';
@@ -11,6 +11,7 @@
import { Confetti } from '$features/ui';
import { courseApi } from '$features/course/api';
import ContentCreateModal from '$features/course/components/content/content-create-modal.svelte';
import CourseCompletionModal from '$features/course/components/ceritficate/course-completion-modal.svelte';
import { profile } from '$lib/utils/store/user';
import { isOrgAdmin } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
@@ -98,6 +99,7 @@
>
<CourseHeader />
<ContentCreateModal />
<CourseCompletionModal />
{#if !isCourseReady}
<div class="mx-auto flex h-[calc(100vh-56px)] w-full items-center justify-center">
@@ -1,10 +1,140 @@
<script lang="ts">
import { CertificatesPage } from '$features/course/pages';
import { RefreshPageData } from '$features/ui';
import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
import { courseApi } from '$features/course/api';
import type { Course } from '$features/course/utils/types';
import { parseCertificateThemeId } from '$features/course/utils/certificate-utils';
import { isFreePlan, isOrgAdmin } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import { Button } from '@cio/ui/base/button';
import ZapIcon from '@lucide/svelte/icons/zap';
import { RefreshPageData, UnsavedChanges } from '$features/ui';
import { ZCourseUpdate } from '@cio/utils/validation/course/course';
import { validateWithTranslation } from '$lib/utils/validation';
import { snackbar } from '$features/ui/snackbar/store';
import { openUpgradeModal } from '$lib/utils/functions/org';
let errors = $state<Record<string, string>>({});
let hasUnsavedChanges = $state(false);
let savedCertificateState = $state<string | null>(null);
let savedCertificateStateCourseId = $state<string | null>(null);
const certificateTheme = $derived(courseApi.course?.certificate?.theme ?? '');
const currentCertificateState = $derived.by(() => getCertificateStateSnapshot(courseApi.course));
const userRole = $derived.by(() => {
const user = courseApi.group.people.find((person) => person.profileId === $profile.id);
return user ? Number(user.roleId) : null;
});
const canEditCertificates = $derived(!!$isOrgAdmin || (userRole !== null && [1, 2].includes(userRole)));
function getCertificateFormState(course: Course | null) {
if (!course) {
return null;
}
return {
description: course.description ?? '',
certificate: {
isDownloadable: course.certificate?.isDownloadable ?? false,
theme: parseCertificateThemeId(course.certificate?.theme) ?? 'professional',
deadline: course.certificate?.deadline ?? null,
threshold: typeof course.certificate?.threshold === 'number' ? course.certificate.threshold : 100,
requiredExerciseId: course.certificate?.requiredExerciseId ?? null,
exerciseMinScorePercent:
typeof course.certificate?.exerciseMinScorePercent === 'number'
? course.certificate.exerciseMinScorePercent
: course.certificate?.requiredExerciseId
? 100
: null,
emailMessage: course.certificate?.emailMessage ?? null
}
};
}
function getCertificateStateSnapshot(course: Course | null) {
const certificateFormState = getCertificateFormState(course);
return certificateFormState ? JSON.stringify(certificateFormState) : null;
}
$effect(() => {
const course = courseApi.course;
const currentSnapshot = getCertificateStateSnapshot(course);
if (!course?.id || !currentSnapshot) {
savedCertificateState = null;
savedCertificateStateCourseId = null;
hasUnsavedChanges = false;
return;
}
if (savedCertificateStateCourseId !== course.id || savedCertificateState === null) {
savedCertificateStateCourseId = course.id;
savedCertificateState = currentSnapshot;
}
hasUnsavedChanges = currentSnapshot !== savedCertificateState;
});
async function saveCertificate() {
if (!courseApi.course) return;
if ($isFreePlan) {
errors = {};
openUpgradeModal();
return;
}
const updatePayload = {
description: courseApi.course.description ?? '',
certificate: {
isDownloadable: courseApi.course.certificate?.isDownloadable ?? false,
theme: certificateTheme || 'professional',
deadline: courseApi.course.certificate?.deadline ?? null,
threshold:
typeof courseApi.course.certificate?.threshold === 'number' ? courseApi.course.certificate.threshold : 100,
requiredExerciseId: courseApi.course.certificate?.requiredExerciseId ?? null,
exerciseMinScorePercent:
typeof courseApi.course.certificate?.exerciseMinScorePercent === 'number'
? courseApi.course.certificate.exerciseMinScorePercent
: courseApi.course.certificate?.requiredExerciseId
? 100
: null,
emailMessage: courseApi.course.certificate?.emailMessage ?? null
}
};
const validated = validateWithTranslation(ZCourseUpdate, updatePayload, 'course');
if ('errors' in validated) {
errors = validated.errors;
return;
}
errors = {};
const updated = await courseApi.update(courseApi.course.id!, validated.data, { showSuccessToast: false });
if (updated) {
savedCertificateState = getCertificateStateSnapshot(courseApi.course);
hasUnsavedChanges = false;
snackbar.success('snackbar.course_settings.success.saved');
}
}
</script>
<svelte:head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Qwitcher+Grypen&family=Roboto:wght@300;400;700&display=swap"
rel="stylesheet"
/>
</svelte:head>
<UnsavedChanges bind:hasUnsavedChanges />
<Page.Root class="mx-auto flex w-[calc(95vw-var(--sidebar-width))]!">
<Page.Header>
<Page.HeaderContent>
@@ -13,12 +143,26 @@
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<RefreshPageData />
<div class="flex w-full justify-end gap-2">
{#if canEditCertificates}
<Button
onclick={saveCertificate}
loading={courseApi.isLoading}
disabled={courseApi.isLoading || !hasUnsavedChanges}
>
{#if $isFreePlan}
<ZapIcon size={16} class="filled" />
{/if}
{$t('course.navItem.certificates.save')}
</Button>
{/if}
<RefreshPageData />
</div>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<CertificatesPage />
<CertificatesPage {errors} />
{/snippet}
</Page.Body>
</Page.Root>
@@ -5,7 +5,7 @@ export const load = async ({ params, cookies }) => {
const exerciseId = params.exerciseId || '';
if (!courseId || !exerciseId) {
return { courseId, exerciseId, exercise: null, submissions: [], mySubmission: null };
return { courseId, exerciseId, exercise: null, submissions: [], mySubmissions: [] };
}
const headers = getApiHeaders(cookies);
@@ -26,7 +26,7 @@ export const load = async ({ params, cookies }) => {
overviewResult.success && overviewResult.data ? overviewResult.data : { mySubmission: [], allSubmissions: [] };
const mySubmissionData = Array.isArray(overview.mySubmission) ? overview.mySubmission : [];
const mySubmission = mySubmissionData.length > 0 ? mySubmissionData[0] : null;
const mySubmissions = mySubmissionData;
const submissions = Array.isArray(overview.allSubmissions) ? overview.allSubmissions : [];
@@ -35,6 +35,6 @@ export const load = async ({ params, cookies }) => {
exerciseId,
exercise,
submissions,
mySubmission
mySubmissions
};
};
@@ -63,7 +63,8 @@
isDescriptionDirty: false,
isDueByDirty: false,
questions: questions,
totalSubmissions: 0
totalSubmissions: 0,
allowMultipleAttempts: !!exercise.allowMultipleAttempts
});
questionnaireMetaData.update((m) => ({ ...m, exerciseId: data.exerciseId }));
});
@@ -84,7 +85,7 @@
goBack={() => goto(resolve(path, {}))}
isFetching={false}
submissions={data.submissions ?? []}
mySubmission={data.mySubmission ?? null}
mySubmissions={data.mySubmissions ?? []}
/>
{/if}
</Page.Root>
@@ -9,6 +9,7 @@
let settingsComponent: CourseSettingsPage | null = $state(null);
let isSaving = $state(false);
let hasUnsavedChanges = $state(false);
let { data } = $props();
async function handleSave() {
@@ -30,7 +31,7 @@
</Page.HeaderContent>
<Page.Action>
<div class="flex items-center gap-2">
<Button variant="secondary" loading={isSaving} onclick={handleSave}>
<Button variant="secondary" loading={isSaving} disabled={isSaving || !hasUnsavedChanges} onclick={handleSave}>
{$t('course.navItem.settings.save')}
</Button>
<RefreshPageData disabled={isSaving} onRefresh={() => courseApi.refreshCourse(data.courseId, $profile.id)} />
@@ -39,7 +40,7 @@
</Page.Header>
<Page.Body>
{#snippet child()}
<CourseSettingsPage bind:this={settingsComponent} />
<CourseSettingsPage bind:this={settingsComponent} bind:hasUnsavedChanges />
{/snippet}
</Page.Body>
</Page.Root>
@@ -1,30 +1,41 @@
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
import {
DEFAULT_ORG_AUDIENCE_QUERY,
getAudienceQueryFromSearchParams,
toAudienceRequestQuery
} from '$lib/features/org/utils/audience-query-utils';
export const load = async ({ parent, cookies }) => {
export const load = async ({ parent, cookies, url }) => {
const { orgId } = await parent();
if (!orgId) {
return {
audience: [],
pagination: null,
query: DEFAULT_ORG_AUDIENCE_QUERY,
courses: []
};
}
const headers = getApiHeaders(cookies, orgId);
const query = getAudienceQueryFromSearchParams(url.searchParams);
const [audienceResponse, coursesResponse] = await Promise.all([
classroomio.organization.audience.$get({}, headers),
classroomio.organization.audience.$get({ query: toAudienceRequestQuery(query)! }, headers),
classroomio.organization.courses.$get({ query: { tags: undefined } }, headers)
]);
const audienceData = await audienceResponse.json();
const audience = audienceData.success ? audienceData.data : [];
const pagination = audienceData.success ? audienceData.pagination : null;
const coursesData = await coursesResponse.json();
const courses = coursesData.success ? coursesData.data : [];
return {
audience,
pagination,
query,
courses
};
};
@@ -17,7 +17,7 @@
isLoading = false;
}
const audienceLength = $derived(data.audience?.length || 0);
const audienceLength = $derived(data.pagination?.total || 0);
</script>
<svelte:head>
@@ -47,7 +47,7 @@
</Page.Header>
<Page.Body>
{#snippet child()}
<AudiencePage audience={data.audience} courses={data.courses} />
<AudiencePage audience={data.audience} pagination={data.pagination} query={data.query} courses={data.courses} />
{/snippet}
</Page.Body>
</Page.Root>
@@ -9,11 +9,6 @@ export const load = async ({ parent, locals, cookies, url }) => {
const { orgId } = await parent();
if (!orgId || !locals.user?.id) {
const loadMs = Math.round((performance.now() - loadStart) * 100) / 100;
console.log(
`[org/[slug]/courses +page.server] load: ${loadMs}ms (skipped: no orgId or user) | PUBLIC_IS_SELFHOSTED=${PUBLIC_IS_SELFHOSTED}`
);
return {
courses: []
};