chore: ai, public course and other ui improvements
This commit is contained in:
@@ -9,13 +9,17 @@ import {
|
||||
createLessons,
|
||||
createOptions,
|
||||
createQuestions,
|
||||
syncOptionIdSequence,
|
||||
getCourseById,
|
||||
getExercisesByLessonIds,
|
||||
getLessonLanguagesByLessonIds,
|
||||
getLessonsByCourseId,
|
||||
getOptionsByQuestionIds,
|
||||
getQuestionsByExerciseIds,
|
||||
getCourseSectionsByCourseId
|
||||
getCourseSectionsByCourseId,
|
||||
getExerciseSectionsByExerciseIds,
|
||||
createExerciseSections,
|
||||
updateExerciseSection
|
||||
} from '@db/queries';
|
||||
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
@@ -72,7 +76,8 @@ async function cloneExercises(newLessons: TLesson[], oldLessons: TLesson[]): Pro
|
||||
title: exercise.title,
|
||||
description: exercise.description,
|
||||
dueBy: new Date().toISOString(),
|
||||
lessonId: lessonIdMap.get(exercise.lessonId!)!
|
||||
lessonId: lessonIdMap.get(exercise.lessonId!)!,
|
||||
sectionDisplayMode: exercise.sectionDisplayMode
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -84,6 +89,47 @@ async function cloneExercises(newLessons: TLesson[], oldLessons: TLesson[]): Pro
|
||||
|
||||
// 6. Fetch all questions for old exercises
|
||||
const oldExerciseIds = oldExercises.map((ex) => ex.id);
|
||||
const oldSections = await getExerciseSectionsByExerciseIds(oldExerciseIds);
|
||||
const exerciseSectionIdMap = new Map<string, string>();
|
||||
|
||||
if (oldSections.length > 0) {
|
||||
const newSections = await createExerciseSections(
|
||||
oldSections.map((section) => ({
|
||||
title: section.title,
|
||||
description: section.description,
|
||||
order: section.order,
|
||||
colorTheme: section.colorTheme,
|
||||
afterBehavior: section.afterBehavior,
|
||||
exerciseId: exerciseIdMap.get(section.exerciseId)!
|
||||
}))
|
||||
);
|
||||
|
||||
oldSections.forEach((oldSection, index) => {
|
||||
const newSection = newSections[index];
|
||||
if (newSection) {
|
||||
exerciseSectionIdMap.set(oldSection.id, newSection.id);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
newSections.map((newSection) => {
|
||||
const behavior = newSection.afterBehavior as { action?: string; exerciseSectionId?: string };
|
||||
if (behavior.action !== 'go_to_section' || !behavior.exerciseSectionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const remappedId = exerciseSectionIdMap.get(behavior.exerciseSectionId);
|
||||
if (!remappedId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return updateExerciseSection(newSection.id, {
|
||||
afterBehavior: { action: 'go_to_section', exerciseSectionId: remappedId }
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const oldQuestions = await getQuestionsByExerciseIds(oldExerciseIds);
|
||||
|
||||
if (!oldQuestions || oldQuestions.length === 0) {
|
||||
@@ -99,6 +145,9 @@ async function cloneExercises(newLessons: TLesson[], oldLessons: TLesson[]): Pro
|
||||
order: question.order,
|
||||
questionTypeId: question.questionTypeId,
|
||||
settings: question.settings,
|
||||
exerciseSectionId: question.exerciseSectionId
|
||||
? (exerciseSectionIdMap.get(question.exerciseSectionId) ?? null)
|
||||
: null,
|
||||
exerciseId: exerciseIdMap.get(question.exerciseId)!
|
||||
}))
|
||||
);
|
||||
@@ -130,6 +179,7 @@ async function cloneExercises(newLessons: TLesson[], oldLessons: TLesson[]): Pro
|
||||
}
|
||||
|
||||
// 10. Insert options with new question IDs
|
||||
await syncOptionIdSequence();
|
||||
await createOptions(
|
||||
oldOptions
|
||||
.map((option) => {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import * as schema from '@cio/db/schema';
|
||||
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import {
|
||||
calcCourseProgressPercent,
|
||||
@@ -12,14 +10,12 @@ import {
|
||||
getStudentSubmissionsForExercise
|
||||
} from '@cio/db/queries/course/certification-exercise';
|
||||
import {
|
||||
getCourseById,
|
||||
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:
|
||||
@@ -255,15 +251,9 @@ export async function evaluateCourseCertification(
|
||||
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);
|
||||
const [courseRow] = await getCourseById(courseId);
|
||||
|
||||
if (!courseMeta?.certificate?.isDownloadable) {
|
||||
if (!courseRow?.certificate?.isDownloadable) {
|
||||
throw new AppError('Certificate download is not enabled for this course', ErrorCodes.VALIDATION_ERROR, 403);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
||||
import { ContentType } from '@cio/utils/constants';
|
||||
import { db, type DbOrTxClient } from '@cio/db/drizzle';
|
||||
import { sql, inArray } from 'drizzle-orm';
|
||||
import { courseSection, lesson, exercise } from '@cio/db/schema';
|
||||
import type { DbOrTxClient } from '@cio/db/drizzle';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import { applyCourseContentBulkUpdates, applyCourseSectionOrderUpdates } from '@cio/db/queries/course/content-batch';
|
||||
import { deleteCourseSection, getCourseSectionById, getCourseSectionsByCourseId } from '@cio/db/queries/course';
|
||||
import { OperationalQueryError } from '@cio/db/queries/query-errors';
|
||||
import { getCourseContentItems, type CourseContentItemRow } from '@cio/db/queries/course/content';
|
||||
import { deleteLesson } from '@cio/db/queries/lesson';
|
||||
import { deleteCourseSection, getCourseSectionById, getCourseSectionsByCourseId } from '@cio/db/queries/course';
|
||||
import { deleteExercise } from '@cio/db/queries/exercise/exercise';
|
||||
import type { TCourseContentDelete, TCourseContentReorder, TCourseContentUpdate } from '@cio/utils/validation/course';
|
||||
|
||||
@@ -17,24 +19,6 @@ type CourseContentReorderResult = {
|
||||
updatedExercises: number;
|
||||
};
|
||||
|
||||
function buildUpdatePayload(item: TCourseContentUpdate['items'][number]) {
|
||||
const payload: Record<string, unknown> = {};
|
||||
|
||||
if (item.isUnlocked !== undefined) {
|
||||
payload.isUnlocked = item.isUnlocked;
|
||||
}
|
||||
|
||||
if (item.order !== undefined) {
|
||||
payload.order = item.order;
|
||||
}
|
||||
|
||||
if (item.sectionId !== undefined) {
|
||||
payload.sectionId = item.sectionId;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function buildContentKey(item: { id: string; type: string }) {
|
||||
return `${item.type}-${item.id}`;
|
||||
}
|
||||
@@ -103,165 +87,37 @@ async function assertCourseSections(courseId: string, sections: NonNullable<TCou
|
||||
});
|
||||
}
|
||||
|
||||
async function applyCourseContentUpdates(tx: DbOrTxClient, items: TCourseContentUpdate['items']) {
|
||||
const updates = items
|
||||
.map((item) => ({
|
||||
item,
|
||||
payload: buildUpdatePayload(item)
|
||||
}))
|
||||
.filter(({ payload }) => Object.keys(payload).length > 0);
|
||||
|
||||
if (updates.length === 0) {
|
||||
return {
|
||||
updatedItems: 0,
|
||||
updatedLessons: 0,
|
||||
updatedExercises: 0
|
||||
};
|
||||
function mapContentBatchError(error: unknown): AppError | null {
|
||||
if (error instanceof OperationalQueryError) {
|
||||
return new AppError(error.message, error.errorCode, error.statusCode as ContentfulStatusCode, error.field);
|
||||
}
|
||||
|
||||
const updatedAt = new Date().toISOString();
|
||||
|
||||
const lessonUpdates = updates.filter(({ item }) => item.type === ContentType.Lesson);
|
||||
const exerciseUpdates = updates.filter(({ item }) => item.type === ContentType.Exercise);
|
||||
|
||||
if (lessonUpdates.length > 0) {
|
||||
const lessonIds = lessonUpdates.map(({ item }) => item.id);
|
||||
const lessonOrderUpdates = lessonUpdates
|
||||
.filter(({ payload }) => payload.order !== undefined)
|
||||
.map(({ item, payload }) => ({ id: item.id, value: payload.order }));
|
||||
const lessonSectionUpdates = lessonUpdates
|
||||
.filter(({ payload }) => payload.sectionId !== undefined)
|
||||
.map(({ item, payload }) => ({ id: item.id, value: payload.sectionId }));
|
||||
const lessonUnlockUpdates = lessonUpdates
|
||||
.filter(({ payload }) => payload.isUnlocked !== undefined)
|
||||
.map(({ item, payload }) => ({ id: item.id, value: payload.isUnlocked }));
|
||||
|
||||
const lessonSet: Record<string, unknown> = {
|
||||
updatedAt
|
||||
};
|
||||
|
||||
if (lessonOrderUpdates.length > 0) {
|
||||
lessonSet.order = sql`case ${lesson.id} ${sql.join(
|
||||
lessonOrderUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
|
||||
sql` `
|
||||
)} else ${lesson.order} end`;
|
||||
}
|
||||
|
||||
if (lessonSectionUpdates.length > 0) {
|
||||
lessonSet.sectionId = sql`case ${lesson.id} ${sql.join(
|
||||
lessonSectionUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
|
||||
sql` `
|
||||
)} else ${lesson.sectionId} end`;
|
||||
}
|
||||
|
||||
if (lessonUnlockUpdates.length > 0) {
|
||||
lessonSet.isUnlocked = sql`case ${lesson.id} ${sql.join(
|
||||
lessonUnlockUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
|
||||
sql` `
|
||||
)} else ${lesson.isUnlocked} end`;
|
||||
}
|
||||
|
||||
const updatedLessons = await tx
|
||||
.update(lesson)
|
||||
.set(lessonSet)
|
||||
.where(inArray(lesson.id, lessonIds))
|
||||
.returning({ id: lesson.id });
|
||||
|
||||
if (updatedLessons.length !== lessonIds.length) {
|
||||
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
|
||||
}
|
||||
}
|
||||
|
||||
if (exerciseUpdates.length > 0) {
|
||||
const exerciseIds = exerciseUpdates.map(({ item }) => item.id);
|
||||
const exerciseOrderUpdates = exerciseUpdates
|
||||
.filter(({ payload }) => payload.order !== undefined)
|
||||
.map(({ item, payload }) => ({ id: item.id, value: payload.order }));
|
||||
const exerciseSectionUpdates = exerciseUpdates
|
||||
.filter(({ payload }) => payload.sectionId !== undefined)
|
||||
.map(({ item, payload }) => ({ id: item.id, value: payload.sectionId }));
|
||||
const exerciseUnlockUpdates = exerciseUpdates
|
||||
.filter(({ payload }) => payload.isUnlocked !== undefined)
|
||||
.map(({ item, payload }) => ({ id: item.id, value: payload.isUnlocked }));
|
||||
|
||||
const exerciseSet: Record<string, unknown> = {
|
||||
updatedAt
|
||||
};
|
||||
|
||||
if (exerciseOrderUpdates.length > 0) {
|
||||
exerciseSet.order = sql`case ${exercise.id} ${sql.join(
|
||||
exerciseOrderUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
|
||||
sql` `
|
||||
)} else ${exercise.order} end`;
|
||||
}
|
||||
|
||||
if (exerciseSectionUpdates.length > 0) {
|
||||
exerciseSet.sectionId = sql`case ${exercise.id} ${sql.join(
|
||||
exerciseSectionUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
|
||||
sql` `
|
||||
)} else ${exercise.sectionId} end`;
|
||||
}
|
||||
|
||||
if (exerciseUnlockUpdates.length > 0) {
|
||||
exerciseSet.isUnlocked = sql`case ${exercise.id} ${sql.join(
|
||||
exerciseUnlockUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
|
||||
sql` `
|
||||
)} else ${exercise.isUnlocked} end`;
|
||||
}
|
||||
|
||||
const updatedExercises = await tx
|
||||
.update(exercise)
|
||||
.set(exerciseSet)
|
||||
.where(inArray(exercise.id, exerciseIds))
|
||||
.returning({ id: exercise.id });
|
||||
|
||||
if (updatedExercises.length !== exerciseIds.length) {
|
||||
throw new AppError('Exercise not found', ErrorCodes.EXERCISE_NOT_FOUND, 404);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
updatedItems: updates.length,
|
||||
updatedLessons: lessonUpdates.length,
|
||||
updatedExercises: exerciseUpdates.length
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
async function applySectionOrderUpdates(tx: DbOrTxClient, sections: NonNullable<TCourseContentReorder['sections']>) {
|
||||
if (sections.length === 0) {
|
||||
return 0;
|
||||
async function transactionWithContentBatch<TResult>(runner: (tx: DbOrTxClient) => Promise<TResult>): Promise<TResult> {
|
||||
try {
|
||||
return await db.transaction(async (tx) => runner(tx));
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const mapped = mapContentBatchError(error);
|
||||
|
||||
if (mapped) {
|
||||
throw mapped;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const sectionIds = sections.map((section) => section.id);
|
||||
const updatedAt = new Date().toISOString();
|
||||
const sectionSet: Record<string, unknown> = {
|
||||
updatedAt,
|
||||
order: sql`case ${courseSection.id} ${sql.join(
|
||||
sections.map((section) => sql`when ${section.id} then ${section.order}`),
|
||||
sql` `
|
||||
)} else ${courseSection.order} end`
|
||||
};
|
||||
|
||||
const updatedSections = await tx
|
||||
.update(courseSection)
|
||||
.set(sectionSet)
|
||||
.where(inArray(courseSection.id, sectionIds))
|
||||
.returning({ id: courseSection.id });
|
||||
|
||||
if (updatedSections.length !== sectionIds.length) {
|
||||
throw new AppError('Course section not found', ErrorCodes.COURSE_SECTION_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return sections.length;
|
||||
}
|
||||
|
||||
export async function updateCourseContent(courseId: string, items: TCourseContentUpdate['items']) {
|
||||
try {
|
||||
await assertCourseContentItems(courseId, items);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await applyCourseContentUpdates(tx, items);
|
||||
});
|
||||
await transactionWithContentBatch(async (tx) => applyCourseContentBulkUpdates(tx, items));
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
@@ -288,10 +144,11 @@ export async function reorderCourseContent(
|
||||
await assertCourseContentItems(courseId, payload.items);
|
||||
}
|
||||
|
||||
return await db.transaction(async (tx) => {
|
||||
const updatedSections = payload.sections?.length ? await applySectionOrderUpdates(tx, payload.sections) : 0;
|
||||
return await transactionWithContentBatch(async (tx) => {
|
||||
const updatedSections = payload.sections?.length ? await applyCourseSectionOrderUpdates(tx, payload.sections) : 0;
|
||||
|
||||
const itemResult = payload.items?.length
|
||||
? await applyCourseContentUpdates(tx, payload.items)
|
||||
? await applyCourseContentBulkUpdates(tx, payload.items)
|
||||
: {
|
||||
updatedItems: 0,
|
||||
updatedLessons: 0,
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { sanitizeHtml, sanitizeOptionalHtml, sanitizeUnknownStrings } from '@api/utils/sanitize-html';
|
||||
import { addGroupMember, createGroup, getCourseProgramAccess } from '@cio/db/queries/group';
|
||||
import {
|
||||
createCourseNewsfeed,
|
||||
createCourse as createCourseQuery,
|
||||
createCourseSections,
|
||||
deleteCourse as deleteCourseQuery,
|
||||
getCourseProgress as getCourseProgressQuery,
|
||||
getCourseTypeById,
|
||||
getCourseWithRelations,
|
||||
updateCourse as updateCourseQuery,
|
||||
updateLessonsSectionId
|
||||
} from '@cio/db/queries/course';
|
||||
import {
|
||||
addGroupMember,
|
||||
createGroup,
|
||||
getCourseProgramAccess,
|
||||
insertGroupMembersOnConflictDoNothing
|
||||
} from '@cio/db/queries/group';
|
||||
import {
|
||||
getLastLogin,
|
||||
getLessonsWithCompletion,
|
||||
@@ -22,11 +28,12 @@ import { ContentType, ROLE } from '@cio/utils/constants';
|
||||
import type { TCourse } from '@cio/db/types';
|
||||
import type { TCourseCreate } from '@cio/utils/validation/course';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import * as schema from '@db/schema';
|
||||
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 { insertOrganizationMembersOnConflictDoNothing } from '@cio/db/queries/organization';
|
||||
import { buildCourseContent, calcPercentageWithRounding, formatLastSeen, type CourseContent } from './utils';
|
||||
import { guardCourseTypeTransition } from './public-course-guard';
|
||||
|
||||
const DEFAULT_CONTENT_GROUPING = true;
|
||||
const DEFAULT_SECTION_TITLE = 'First Section [edit me]';
|
||||
@@ -45,26 +52,30 @@ export async function ensureProgramCourseAccess(courseId: string, profileId: str
|
||||
const normalizedEmail = access.profileEmail?.trim() || undefined;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.insert(schema.organizationmember)
|
||||
.values({
|
||||
organizationId,
|
||||
roleId: access.roleId,
|
||||
profileId,
|
||||
email: normalizedEmail,
|
||||
verified: true
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
await insertOrganizationMembersOnConflictDoNothing(
|
||||
[
|
||||
{
|
||||
organizationId,
|
||||
roleId: access.roleId,
|
||||
profileId,
|
||||
email: normalizedEmail,
|
||||
verified: true
|
||||
}
|
||||
],
|
||||
tx
|
||||
);
|
||||
|
||||
await tx
|
||||
.insert(schema.groupmember)
|
||||
.values({
|
||||
groupId: access.courseGroupId,
|
||||
roleId: access.roleId,
|
||||
profileId,
|
||||
email: normalizedEmail
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
await insertGroupMembersOnConflictDoNothing(
|
||||
[
|
||||
{
|
||||
groupId: access.courseGroupId,
|
||||
roleId: access.roleId,
|
||||
profileId,
|
||||
email: normalizedEmail ?? undefined
|
||||
}
|
||||
],
|
||||
tx
|
||||
);
|
||||
});
|
||||
|
||||
return true;
|
||||
@@ -266,6 +277,16 @@ export async function updateCourse(courseId: string, data: Partial<TCourse>) {
|
||||
slug: data.slug
|
||||
};
|
||||
|
||||
if (data.type !== undefined) {
|
||||
const currentType = await getCourseTypeById(courseId);
|
||||
|
||||
await guardCourseTypeTransition({
|
||||
courseId,
|
||||
currentType: currentType ?? null,
|
||||
nextType: data.type
|
||||
});
|
||||
}
|
||||
|
||||
if (data.certificate?.requiredExerciseId) {
|
||||
const ok = await exerciseBelongsToCourse(data.certificate.requiredExerciseId, courseId);
|
||||
if (!ok) {
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import * as schema from '@cio/db/schema';
|
||||
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import {
|
||||
countInviteDistinctPreviewIps,
|
||||
createCourseInvite,
|
||||
@@ -11,14 +7,25 @@ import {
|
||||
listCourseInviteAudit,
|
||||
listCourseInviteAuditStats,
|
||||
listCourseInvites,
|
||||
revokeCourseInvite
|
||||
optimisticIncrementCourseInviteUsedCount,
|
||||
revokeCourseInvite,
|
||||
selectCourseInviteAcceptBundleByTokenHash
|
||||
} from '@cio/db/queries/course/invite';
|
||||
import { getCourseById, getCourseWithOrgData, getCourseWithRelations } from '@cio/db/queries/course';
|
||||
import { createOrganizationMember, getOrganizationMemberIdByOrgAndProfile } from '@cio/db/queries/organization';
|
||||
import { addGroupMember, getGroupMemberIdByGroupAndProfile } from '@cio/db/queries/group';
|
||||
import {
|
||||
getCourseById,
|
||||
getCourseWithOrgData,
|
||||
getCourseWithRelations,
|
||||
isCourseSlugTaken,
|
||||
updateCourseSlug
|
||||
} from '@cio/db/queries/course';
|
||||
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import type { TCreateCourseInvite } from '@cio/utils/validation/course/invite';
|
||||
import type { TNewCourseInviteAudit } from '@db/types';
|
||||
import crypto from 'node:crypto';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
||||
import { getCourseTeachers } from '@cio/db/queries/course/people';
|
||||
import { getProfileById } from '@cio/db/queries/auth';
|
||||
@@ -26,6 +33,7 @@ import { buildEmailFromName, sendEmail } from '@cio/email';
|
||||
import { getProfileByEmail, markUserAndProfileEmailVerified } from '@cio/db/queries/auth';
|
||||
import { generateSlug } from '@cio/utils/functions';
|
||||
import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
|
||||
type InviteStatus = 'ACTIVE' | 'EXPIRED' | 'USED_UP' | 'REVOKED';
|
||||
type InvitePreset = 'ONE_TIME_24H' | 'MULTI_USE_7D' | 'MULTI_USE_30D' | 'CUSTOM';
|
||||
@@ -46,13 +54,9 @@ async function generateUniqueCourseSlug(baseSlug: string): Promise<string> {
|
||||
let candidate = baseSlug;
|
||||
|
||||
while (true) {
|
||||
const existing = await db
|
||||
.select({ id: schema.course.id })
|
||||
.from(schema.course)
|
||||
.where(eq(schema.course.slug, candidate))
|
||||
.limit(1);
|
||||
const taken = await isCourseSlugTaken(candidate);
|
||||
|
||||
if (existing.length === 0) {
|
||||
if (!taken) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
@@ -65,11 +69,7 @@ async function ensureCourseSlug(courseId: string, title: string | null | undefin
|
||||
const baseSlug = generateSlug(title, { fallback: 'course' });
|
||||
const uniqueSlug = await generateUniqueCourseSlug(baseSlug);
|
||||
|
||||
const [updated] = await db
|
||||
.update(schema.course)
|
||||
.set({ slug: uniqueSlug, updatedAt: new Date().toISOString() })
|
||||
.where(eq(schema.course.id, courseId))
|
||||
.returning({ slug: schema.course.slug });
|
||||
const updated = await updateCourseSlug(courseId, uniqueSlug);
|
||||
|
||||
return updated?.slug ?? uniqueSlug;
|
||||
}
|
||||
@@ -276,7 +276,7 @@ function toInviteResponse(
|
||||
async function recordInviteAudit(
|
||||
inviteId: string,
|
||||
courseId: string,
|
||||
eventType: (typeof schema.courseInviteEventType.enumValues)[number],
|
||||
eventType: TNewCourseInviteAudit['eventType'],
|
||||
context: {
|
||||
actorProfileId?: string | null;
|
||||
targetEmail?: string | null;
|
||||
@@ -641,13 +641,9 @@ export async function enrollInCourse(
|
||||
|
||||
const normalizedEmail = user.email.toLowerCase().trim();
|
||||
|
||||
const [existingMember] = await db
|
||||
.select({ id: schema.groupmember.id })
|
||||
.from(schema.groupmember)
|
||||
.where(and(eq(schema.groupmember.groupId, groupId), eq(schema.groupmember.profileId, user.id)))
|
||||
.limit(1);
|
||||
const existingMemberId = await getGroupMemberIdByGroupAndProfile(groupId, user.id);
|
||||
|
||||
if (existingMember) {
|
||||
if (existingMemberId) {
|
||||
await ensureComplianceEnrollmentRecordsForProfiles([courseId], [user.id]);
|
||||
|
||||
return {
|
||||
@@ -657,14 +653,10 @@ export async function enrollInCourse(
|
||||
};
|
||||
}
|
||||
|
||||
const [orgMember] = await db
|
||||
.select({ id: schema.organizationmember.id })
|
||||
.from(schema.organizationmember)
|
||||
.where(and(eq(schema.organizationmember.organizationId, org.id), eq(schema.organizationmember.profileId, user.id)))
|
||||
.limit(1);
|
||||
const orgMemberId = await getOrganizationMemberIdByOrgAndProfile(org.id, user.id);
|
||||
|
||||
if (!orgMember) {
|
||||
await db.insert(schema.organizationmember).values({
|
||||
if (!orgMemberId) {
|
||||
await createOrganizationMember({
|
||||
organizationId: org.id,
|
||||
roleId: ROLE.STUDENT,
|
||||
profileId: user.id,
|
||||
@@ -673,7 +665,7 @@ export async function enrollInCourse(
|
||||
});
|
||||
}
|
||||
|
||||
await db.insert(schema.groupmember).values({
|
||||
await addGroupMember({
|
||||
groupId,
|
||||
roleId: ROLE.STUDENT,
|
||||
profileId: user.id,
|
||||
@@ -901,29 +893,7 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
|
||||
const tokenHash = hashToken(token);
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const [inviteRow] = await tx
|
||||
.select({
|
||||
invite: schema.courseInvite,
|
||||
course: {
|
||||
id: schema.course.id,
|
||||
title: schema.course.title,
|
||||
status: schema.course.status,
|
||||
isPublished: schema.course.isPublished,
|
||||
metadata: schema.course.metadata,
|
||||
groupId: schema.group.id
|
||||
},
|
||||
organization: {
|
||||
id: schema.organization.id,
|
||||
name: schema.organization.name,
|
||||
siteName: schema.organization.siteName
|
||||
}
|
||||
})
|
||||
.from(schema.courseInvite)
|
||||
.innerJoin(schema.course, eq(schema.courseInvite.courseId, schema.course.id))
|
||||
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
|
||||
.innerJoin(schema.organization, eq(schema.group.organizationId, schema.organization.id))
|
||||
.where(eq(schema.courseInvite.tokenHash, tokenHash))
|
||||
.limit(1);
|
||||
const inviteRow = await selectCourseInviteAcceptBundleByTokenHash(tx, tokenHash);
|
||||
|
||||
if (!inviteRow) {
|
||||
throw new AppError('Invalid invite link', ErrorCodes.NOT_FOUND, 404);
|
||||
@@ -948,15 +918,9 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
|
||||
}
|
||||
|
||||
// Idempotent behavior: if user is already enrolled, don't consume invite again.
|
||||
const [existingMember] = await tx
|
||||
.select({
|
||||
id: schema.groupmember.id
|
||||
})
|
||||
.from(schema.groupmember)
|
||||
.where(and(eq(schema.groupmember.groupId, course.groupId), eq(schema.groupmember.profileId, user.id)))
|
||||
.limit(1);
|
||||
const existingMemberId = await getGroupMemberIdByGroupAndProfile(course.groupId, user.id, tx);
|
||||
|
||||
if (existingMember) {
|
||||
if (existingMemberId) {
|
||||
await markUserAndProfileEmailVerified(user.id, tx);
|
||||
|
||||
await recordInviteAudit(invite.id, invite.courseId, 'ACCEPTED', {
|
||||
@@ -1011,49 +975,34 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
|
||||
throw new AppError('This invite is restricted to specific email domains', ErrorCodes.UNAUTHORIZED, 403);
|
||||
}
|
||||
|
||||
const [orgMember] = await tx
|
||||
.select({
|
||||
id: schema.organizationmember.id
|
||||
})
|
||||
.from(schema.organizationmember)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.organizationmember.organizationId, organization.id),
|
||||
eq(schema.organizationmember.profileId, user.id)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
const orgMemberId = await getOrganizationMemberIdByOrgAndProfile(organization.id, user.id, tx);
|
||||
|
||||
if (!orgMember) {
|
||||
await tx.insert(schema.organizationmember).values({
|
||||
organizationId: organization.id,
|
||||
roleId: ROLE.STUDENT,
|
||||
profileId: user.id,
|
||||
email: normalizedEmail,
|
||||
verified: true
|
||||
});
|
||||
if (!orgMemberId) {
|
||||
await createOrganizationMember(
|
||||
{
|
||||
organizationId: organization.id,
|
||||
roleId: ROLE.STUDENT,
|
||||
profileId: user.id,
|
||||
email: normalizedEmail,
|
||||
verified: true
|
||||
},
|
||||
tx
|
||||
);
|
||||
}
|
||||
|
||||
await tx.insert(schema.groupmember).values({
|
||||
groupId: course.groupId,
|
||||
roleId: ROLE.STUDENT,
|
||||
profileId: user.id,
|
||||
email: normalizedEmail
|
||||
});
|
||||
await addGroupMember(
|
||||
{
|
||||
groupId: course.groupId,
|
||||
roleId: ROLE.STUDENT,
|
||||
profileId: user.id,
|
||||
email: normalizedEmail
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
await markUserAndProfileEmailVerified(user.id, tx);
|
||||
|
||||
const [consumeInvite] = await tx
|
||||
.update(schema.courseInvite)
|
||||
.set({
|
||||
usedCount: invite.usedCount + 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
lastUsedAt: new Date().toISOString()
|
||||
})
|
||||
.where(and(eq(schema.courseInvite.id, invite.id), eq(schema.courseInvite.usedCount, invite.usedCount)))
|
||||
.returning({
|
||||
id: schema.courseInvite.id
|
||||
});
|
||||
const consumeInvite = await optimisticIncrementCourseInviteUsedCount(tx, invite.id, invite.usedCount);
|
||||
|
||||
if (!consumeInvite) {
|
||||
throw new AppError('This invite was already used. Please try another link.', ErrorCodes.VALIDATION_ERROR, 409);
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import * as schema from '@cio/db/schema';
|
||||
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import type { TCourse, TOrganization } from '@db/types';
|
||||
import type { TCourseLandingPageMetadataUpdate, TCourseLandingPageUpdate } from '@cio/utils/validation/course';
|
||||
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import { env } from '@api/config/env';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { generateSlug } from '@cio/utils/functions';
|
||||
import { getCourseById } from '@cio/db/queries/course';
|
||||
import { getCourseById, isCourseSlugTaken, updateCourseSlug } from '@cio/db/queries/course';
|
||||
import { getCourseOrganizationId } from '@cio/db/queries/tag';
|
||||
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
||||
import { getOrganizationById } from '@cio/db/queries/organization';
|
||||
@@ -25,13 +21,8 @@ export async function generateUniqueCourseSlug(baseSlug: string): Promise<string
|
||||
let candidate = baseSlug;
|
||||
|
||||
while (true) {
|
||||
const existing = await db
|
||||
.select({ id: schema.course.id })
|
||||
.from(schema.course)
|
||||
.where(eq(schema.course.slug, candidate))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
const taken = await isCourseSlugTaken(candidate);
|
||||
if (!taken) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
@@ -48,8 +39,13 @@ export async function ensureCourseSlug(courseId: string, title: string | null |
|
||||
|
||||
const baseSlug = generateSlug(title, { fallback: 'course' });
|
||||
const uniqueSlug = await generateUniqueCourseSlug(baseSlug);
|
||||
await updateCourse(courseId, { slug: uniqueSlug });
|
||||
return uniqueSlug;
|
||||
const updated = await updateCourseSlug(courseId, uniqueSlug);
|
||||
|
||||
if (!updated) {
|
||||
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return updated.slug ?? uniqueSlug;
|
||||
}
|
||||
|
||||
export function buildCourseBaseUrl(
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { findNonAutoGradableQuestionsInCourse, getCourseTypeById } from '@cio/db/queries/course';
|
||||
import { AUTO_GRADABLE_QUESTION_TYPE_IDS, isAutoGradableQuestionTypeId } from '@cio/question-types';
|
||||
|
||||
/**
|
||||
* Throws `QUESTION_TYPE_NOT_ALLOWED_IN_PUBLIC_COURSE` when a non-auto-gradable question
|
||||
* type is being attached to an exercise that belongs to a `PUBLIC` course.
|
||||
*
|
||||
* Accepts the full list of question type ids being created / updated (ignore-null safe).
|
||||
*/
|
||||
export async function guardNonAutoGradableQuestionsForCourseType(params: {
|
||||
courseId: string;
|
||||
questionTypeIds: Array<number | null | undefined>;
|
||||
}): Promise<void> {
|
||||
const typeIds = params.questionTypeIds.filter((id): id is number => typeof id === 'number');
|
||||
if (typeIds.length === 0) return;
|
||||
|
||||
const hasDisallowed = typeIds.some((id) => !isAutoGradableQuestionTypeId(id));
|
||||
if (!hasDisallowed) return;
|
||||
|
||||
const rowType = await getCourseTypeById(params.courseId);
|
||||
|
||||
if (!rowType) return;
|
||||
|
||||
if (rowType === 'PUBLIC') {
|
||||
throw new AppError(
|
||||
'Public courses only support auto-gradable question types',
|
||||
ErrorCodes.QUESTION_TYPE_NOT_ALLOWED_IN_PUBLIC_COURSE,
|
||||
400,
|
||||
'questions'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When converting a course to `PUBLIC`, enumerate any questions whose type is not
|
||||
* auto-gradable. Throws `PUBLIC_COURSE_CONVERSION_BLOCKED` with the offending list
|
||||
* in `message`; callers should surface the list to the creator so they can fix them.
|
||||
*/
|
||||
export async function guardCourseTypeTransition(params: {
|
||||
courseId: string;
|
||||
currentType: string | null;
|
||||
nextType: string | null | undefined;
|
||||
}): Promise<void> {
|
||||
if (!params.nextType) return;
|
||||
if (params.nextType !== 'PUBLIC') return;
|
||||
if (params.currentType === 'PUBLIC') return;
|
||||
|
||||
const offenders = await findNonAutoGradableQuestionsInCourse(params.courseId, AUTO_GRADABLE_QUESTION_TYPE_IDS);
|
||||
if (offenders.length === 0) return;
|
||||
|
||||
const preview = offenders.slice(0, 5).map((item) => `"${item.questionTitle}" in "${item.exerciseTitle}"`);
|
||||
const more = offenders.length > 5 ? ` (+${offenders.length - 5} more)` : '';
|
||||
|
||||
throw new AppError(
|
||||
`Remove or replace these non-auto-gradable questions before converting to Public: ${preview.join('; ')}${more}`,
|
||||
ErrorCodes.PUBLIC_COURSE_CONVERSION_BLOCKED,
|
||||
400,
|
||||
'type'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import {
|
||||
getCourseRowBySlug,
|
||||
getPublicCourseItem as getPublicCourseItemQuery,
|
||||
getPublicCourseTreeBySlug,
|
||||
type PublicCourseItemContent,
|
||||
type PublicCourseTree
|
||||
} from '@cio/db/queries/course';
|
||||
import { getExerciseWithRelationsOptimized } from '@cio/db/queries/exercise';
|
||||
import { getExerciseSectionsByExerciseId } from '@cio/db/queries/exercise/exercise-section';
|
||||
import { fetchQuestionsAndOptions, transformQuestions } from '@api/services/exercise/utils';
|
||||
|
||||
/**
|
||||
* Anonymous-safe: fetch the full tree for a published public course by slug.
|
||||
* Callers should return 404 when this resolves to `null`.
|
||||
*/
|
||||
export async function getPublicCourseTreeService(courseSlug: string): Promise<PublicCourseTree | null> {
|
||||
try {
|
||||
return await getPublicCourseTreeBySlug(courseSlug);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError('Failed to load public course', ErrorCodes.COURSE_FETCH_FAILED, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export type PublicItemResponse =
|
||||
| (Extract<PublicCourseItemContent, { kind: 'lesson' }> & { kind: 'lesson' })
|
||||
| (Extract<PublicCourseItemContent, { kind: 'exercise' }> & {
|
||||
kind: 'exercise';
|
||||
questions: PublicExercisePayload;
|
||||
});
|
||||
|
||||
export interface PublicExercisePayload {
|
||||
unsectioned: PublicExerciseQuestion[];
|
||||
sections: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
order: number;
|
||||
colorTheme: string;
|
||||
afterBehavior: unknown;
|
||||
questions: PublicExerciseQuestion[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface PublicExerciseQuestion {
|
||||
id: number | string;
|
||||
title: string;
|
||||
questionTypeId: number;
|
||||
questionTypeKey: string;
|
||||
points: number;
|
||||
order: number | null;
|
||||
settings: Record<string, unknown>;
|
||||
options: Array<{
|
||||
id: number | string;
|
||||
label: string;
|
||||
/** Answer keys are intentionally shipped to the client for PUBLIC courses. */
|
||||
isCorrect: boolean;
|
||||
settings: Record<string, unknown>;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anonymous-safe: fetch a single lesson or exercise by its per-course slug.
|
||||
* For exercises, questions + options are returned with answer keys included
|
||||
* because PUBLIC courses grade entirely on the client.
|
||||
*/
|
||||
export async function getPublicCourseItemService(
|
||||
courseSlug: string,
|
||||
itemSlug: string
|
||||
): Promise<PublicItemResponse | null> {
|
||||
try {
|
||||
const baseItem = await getPublicCourseItemQuery(courseSlug, itemSlug);
|
||||
if (!baseItem) return null;
|
||||
|
||||
if (baseItem.kind === 'lesson') {
|
||||
return baseItem;
|
||||
}
|
||||
|
||||
const { exercise, questions } = await getExerciseWithRelationsOptimized(baseItem.id);
|
||||
const questionTypeMap = new Map(questions.map((question) => [question.questionType.id, question.questionType]));
|
||||
const allOptions = questions.flatMap((question) => question.options ?? []);
|
||||
const strippedQuestions = questions.map(({ questionType: _type, options: _options, ...rest }) => rest);
|
||||
const transformed = transformQuestions(strippedQuestions, allOptions, questionTypeMap);
|
||||
const sectionRows = await getExerciseSectionsByExerciseId(exercise.id);
|
||||
|
||||
const sectionMap = new Map(sectionRows.map((section) => [section.id, section]));
|
||||
const unsectioned: PublicExerciseQuestion[] = [];
|
||||
const sectionBuckets = new Map<string, PublicExerciseQuestion[]>();
|
||||
|
||||
for (const question of transformed) {
|
||||
// `transformQuestions` only keeps `{ id, label }` on `question.questionType`, so the DB
|
||||
// `typename` (e.g. RADIO) must come from the map built before stripping relations.
|
||||
const questionTypeRow = questionTypeMap.get(question.questionTypeId);
|
||||
const payloadQuestion: PublicExerciseQuestion = {
|
||||
id: question.id,
|
||||
title: question.title ?? '',
|
||||
questionTypeId: question.questionTypeId,
|
||||
questionTypeKey: questionTypeRow?.typename ?? '',
|
||||
points: question.points ?? 0,
|
||||
order: typeof question.order === 'number' ? question.order : null,
|
||||
settings: (question.settings ?? {}) as Record<string, unknown>,
|
||||
options: (question.options ?? []).map((option) => ({
|
||||
id: option.id,
|
||||
label: option.label ?? '',
|
||||
isCorrect: option.isCorrect ?? false,
|
||||
settings: (option.settings ?? {}) as Record<string, unknown>
|
||||
}))
|
||||
};
|
||||
|
||||
const sectionId = question.exerciseSectionId ?? null;
|
||||
if (sectionId && sectionMap.has(sectionId)) {
|
||||
const bucket = sectionBuckets.get(sectionId) ?? [];
|
||||
bucket.push(payloadQuestion);
|
||||
sectionBuckets.set(sectionId, bucket);
|
||||
} else {
|
||||
unsectioned.push(payloadQuestion);
|
||||
}
|
||||
}
|
||||
|
||||
const sections = sectionRows.map((section) => ({
|
||||
id: section.id,
|
||||
title: section.title,
|
||||
description: section.description,
|
||||
order: Number(section.order),
|
||||
colorTheme: section.colorTheme,
|
||||
afterBehavior: section.afterBehavior,
|
||||
questions: sectionBuckets.get(section.id) ?? []
|
||||
}));
|
||||
|
||||
return {
|
||||
...baseItem,
|
||||
questions: {
|
||||
unsectioned,
|
||||
sections
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
console.error('getPublicCourseItemService error:', error);
|
||||
throw new AppError('Failed to load public course item', ErrorCodes.COURSE_FETCH_FAILED, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a course slug to the backing course row with minimal fields. Used by
|
||||
* rate limiting / logging that need the course id without the full tree.
|
||||
*/
|
||||
export async function resolvePublicCourseBySlug(courseSlug: string) {
|
||||
return getCourseRowBySlug(courseSlug);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { getTakenItemSlugs } from '@cio/db/queries/course';
|
||||
import { SLUG_REGEX, resolveSlugCollision, slugifyTitle } from '@cio/utils/validation';
|
||||
|
||||
/**
|
||||
* Resolve an item (lesson or exercise) slug for a given course:
|
||||
* - If `requestedSlug` is provided, validate it and reject if already taken (excluding `ignoreItemId`).
|
||||
* - Otherwise derive a slug from `title` and append `-2`, `-3`, ... until free.
|
||||
*
|
||||
* Uniqueness is enforced across the union of `lesson.slug` and `exercise.slug` per course.
|
||||
*/
|
||||
export async function resolveItemSlug(params: {
|
||||
courseId: string;
|
||||
title: string;
|
||||
requestedSlug?: string;
|
||||
ignoreItemSlug?: string;
|
||||
}): Promise<string> {
|
||||
const { courseId, title, requestedSlug, ignoreItemSlug } = params;
|
||||
const takenSlugs = await getTakenItemSlugs(courseId);
|
||||
|
||||
if (ignoreItemSlug) {
|
||||
takenSlugs.delete(ignoreItemSlug);
|
||||
}
|
||||
|
||||
if (requestedSlug) {
|
||||
if (!SLUG_REGEX.test(requestedSlug)) {
|
||||
throw new AppError(
|
||||
'Slug must be lowercase letters, numbers, and single dashes',
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
400,
|
||||
'slug'
|
||||
);
|
||||
}
|
||||
if (takenSlugs.has(requestedSlug)) {
|
||||
throw new AppError('Slug is already in use in this course', ErrorCodes.SLUG_IN_USE, 409, 'slug');
|
||||
}
|
||||
return requestedSlug;
|
||||
}
|
||||
|
||||
const base = slugifyTitle(title);
|
||||
return resolveSlugCollision(base, takenSlugs);
|
||||
}
|
||||
Reference in New Issue
Block a user