chore: ai, public course and other ui improvements

This commit is contained in:
Rotimi Best
2026-05-09 06:05:03 +01:00
parent 5717dd8004
commit c31ad63955
318 changed files with 20668 additions and 4788 deletions
+52 -2
View File
@@ -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) => {
+3 -13
View File
@@ -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);
}
+30 -173
View File
@@ -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,
+42 -21
View File
@@ -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) {
+50 -101
View File
@@ -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);
+10 -14
View File
@@ -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);
}
+42
View File
@@ -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);
}