feat: certification rules, content sanitization, handle draft course updates and reset content, better redis logging
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 🎉 </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 🎉 </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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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)`)
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+42
@@ -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) => {
|
||||
|
||||
+134
@@ -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>
|
||||
+186
@@ -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>
|
||||
+57
@@ -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>
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#if courseApi.course?.isCertificateDownloadable === true}
|
||||
{#if courseApi.course?.certificate?.isDownloadable === true}
|
||||
<UnlockedCertificate />
|
||||
{:else}
|
||||
<LockedCertificate />
|
||||
|
||||
+82
-17
@@ -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')}
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+1
-1
@@ -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 > 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>
|
||||
|
||||
+56
@@ -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
-2
@@ -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>
|
||||
|
||||
+118
-66
@@ -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}
|
||||
|
||||
+13
-11
@@ -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>
|
||||
|
||||
|
||||
+2
-1
@@ -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"
|
||||
|
||||
+2
-2
@@ -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 > 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[]>([]);
|
||||
|
||||
@@ -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
|
||||
}));
|
||||
}
|
||||
|
||||
+15
-7
@@ -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": {
|
||||
|
||||
@@ -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: []
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user