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
@@ -0,0 +1,239 @@
import { z } from 'zod';
import { CoursePlanSchema } from '@cio/ai-assistant';
import { ZExerciseSectionAfterBehavior } from '@cio/utils/validation/exercise';
import { ZCourseLandingPageUpdate, ZCourseLandingPageMetadataUpdate } from '@cio/utils/validation/course';
// courseId is NOT a parameter — it's injected from the authenticated request context.
// This prevents prompt injection from tricking the LLM into targeting another course.
export const emptyParam = z.object({});
export const lessonReadParam = z.object({ lessonId: z.string(), locale: z.string().default('en') });
export const exerciseReadParam = z.object({ exerciseId: z.string() });
export const createSectionParam = z.object({ title: z.string().min(1), order: z.number().int().min(0) });
export const updateSectionParam = z
.object({
sectionId: z.string(),
title: z.string().min(1).optional(),
order: z.number().int().min(0).optional()
})
.refine((data) => data.title !== undefined || data.order !== undefined, {
message: 'Provide at least one field to update'
});
export const createLessonParam = z.object({
sectionId: z.string(),
title: z.string().min(1),
order: z.number().int().min(0)
});
export const updateLessonParam = z
.object({
lessonId: z.string(),
title: z.string().min(1).optional(),
sectionId: z.string().optional(),
order: z.number().int().min(0).optional(),
lessonAt: z.string().optional(),
callUrl: z.string().optional(),
isUnlocked: z.boolean().optional(),
public: z.boolean().optional()
})
.refine(
(data) =>
data.title !== undefined ||
data.sectionId !== undefined ||
data.order !== undefined ||
data.lessonAt !== undefined ||
data.callUrl !== undefined ||
data.isUnlocked !== undefined ||
data.public !== undefined,
{
message: 'Provide at least one field to update'
}
);
export const updateContentParam = z.object({
lessonId: z.string(),
locale: z.string().default('en'),
content: z.string().min(1)
});
export const questionSchema = z.object({
question: z.string().min(1),
questionTypeId: z.number().int().min(1).max(14).default(1),
points: z.number().min(0).default(1),
order: z.number().int().min(0),
options: z.array(z.object({ label: z.string().min(1), isCorrect: z.boolean() }))
});
export const createExerciseParam = z.object({
lessonId: z.string().optional(),
sectionId: z.string().optional(),
title: z.string().min(1),
description: z.string().optional().describe('Optional short description shown to students above the questions.'),
order: z
.number()
.int()
.min(0)
.optional()
.describe(
'Display order within the section (0-based). Required when sectionId is provided. Lessons and exercises share the same order space within a section.'
),
questions: z.array(questionSchema)
});
export const updateExerciseParam = z
.object({
exerciseId: z.string().describe('The exercise to update. Must be a real UUID returned by a prior tool call.'),
title: z.string().min(1).optional().describe('New exercise title. Omit to keep unchanged.'),
description: z
.string()
.optional()
.describe('New short description shown to students. Pass an empty string to clear it.'),
lessonId: z.string().optional().describe('Link the exercise to a lesson in this course.'),
sectionId: z.string().optional().describe('Move the exercise to a section in this course.'),
order: z.number().int().min(0).optional().describe('New order within the section (0-based).'),
dueBy: z
.string()
.optional()
.describe('Due date in ISO 8601 format (e.g. 2026-05-01T23:59:00Z). Omit to keep unchanged.'),
isUnlocked: z.boolean().optional().describe('Whether the exercise is unlocked for students.'),
allowMultipleAttempts: z.boolean().optional().describe('Whether students can re-take the exercise.')
})
.refine(
(data) =>
data.title !== undefined ||
data.description !== undefined ||
data.lessonId !== undefined ||
data.sectionId !== undefined ||
data.order !== undefined ||
data.dueBy !== undefined ||
data.isUnlocked !== undefined ||
data.allowMultipleAttempts !== undefined,
{
message: 'Provide at least one field to update'
}
);
export const updateExerciseSectionParam = z
.object({
exerciseId: z
.string()
.describe('The exercise that contains this section. Must match the exercise you read with get_exercise_details.'),
exerciseSectionId: z
.string()
.describe('The exercise section id from get_exercise_details `sections[].id` — not a course section id.'),
title: z.string().min(1).optional().describe('New section heading shown above questions in this block.'),
description: z.string().optional().describe('Optional intro HTML for this block. Pass an empty string to clear it.')
})
.refine((data) => data.title !== undefined || data.description !== undefined, {
message: 'Provide at least title or description to update'
});
export const createExerciseSectionParam = z.object({
exerciseId: z.string().describe('The exercise that will contain this section.'),
title: z.string().min(1).describe('Section heading shown above the questions in this block.'),
description: z
.string()
.optional()
.describe('Optional intro HTML for this block. Pass an empty string to leave it blank.'),
order: z.number().int().min(0).describe('Display order within the exercise (0-based).'),
colorTheme: z.enum(['blue', 'green', 'amber', 'rose', 'violet', 'slate']).optional(),
afterBehavior: ZExerciseSectionAfterBehavior.optional()
});
export const addQuestionsParam = z.object({
exerciseId: z.string(),
exerciseSectionId: z
.string()
.uuid()
.optional()
.describe(
'In-exercise section id from get_exercise_details `sections[].id` where the new questions should appear (not a course outline section id).'
),
questions: z.array(questionSchema)
});
export const updateOptionSchema = z.object({
id: z.number().int().optional(),
label: z.string().min(1).optional(),
isCorrect: z.boolean().optional(),
settings: z.record(z.string(), z.unknown()).optional()
});
export const updateQuestionPatchSchema = z.object({
id: z.number().int(),
question: z.string().min(1).optional(),
questionTypeId: z.number().int().min(1).max(14).optional(),
points: z.number().min(0).optional(),
order: z.number().int().min(0).optional(),
exerciseSectionId: z
.string()
.uuid()
.nullable()
.optional()
.describe(
'Move the question to this in-exercise block (get_exercise_details sections[].id). Use null to clear section assignment.'
),
settings: z.record(z.string(), z.unknown()).optional(),
options: z.array(updateOptionSchema).optional()
});
export const updateQuestionsParam = z.object({
exerciseId: z.string(),
questions: z.array(updateQuestionPatchSchema).min(1)
});
export const coursePlanParam = z.object({ plan: CoursePlanSchema });
// Gemini's tool-schema validator only accepts string enums, so numeric/boolean
// `z.literal` values must be relaxed to plain types in the schemas the model sees.
export const agentLessonTabsOrder = z.array(
z.object({
id: z.number().int().min(1).max(4),
name: z.string()
})
);
export const agentLandingPageMetadataUpdate = ZCourseLandingPageMetadataUpdate.extend({
lessonTabsOrder: agentLessonTabsOrder.optional()
});
export const updateCourseLandingPageParam = ZCourseLandingPageUpdate.extend({
metadata: agentLandingPageMetadataUpdate.optional()
}).refine(
(data) =>
data.title !== undefined ||
data.description !== undefined ||
data.overview !== undefined ||
data.cost !== undefined ||
data.currency !== undefined ||
data.imageUrl !== undefined ||
data.generateImage !== undefined ||
data.imageQuery !== undefined ||
data.metadata !== undefined,
{
message: 'Provide at least one landing-page field to update'
}
);
export const goLiveParam = z.object({
confirmPublish: z
.boolean()
.describe('Must be true. Only use this after the teacher explicitly asks to publish or go live.')
});
export const reorderContentParam = z.object({
sections: z
.array(
z.object({
id: z.string().min(1),
order: z.number().int().min(0)
})
)
.optional()
.describe('Reorder sections by setting new order values'),
items: z
.array(
z.object({
id: z.string().min(1),
type: z.enum(['LESSON', 'EXERCISE']),
order: z.number().int().min(0).optional().describe('New order within the section'),
sectionId: z.string().nullable().optional().describe('Move item to a different section')
})
)
.optional()
.describe('Reorder or move lessons/exercises')
});
+160
View File
@@ -0,0 +1,160 @@
import { AppError } from '@api/utils/errors';
import { getDocumentText } from '@api/services/agent/document';
import { redis } from '@api/utils/redis/redis';
import { getCourseSectionBinding, getExerciseCourseBinding, getLessonCourseBinding } from '@cio/db/queries/agent';
import { z } from 'zod';
import { CoursePlanSchema } from '@cio/ai-assistant';
type ResourceOwnershipRow = {
courseId: string | null;
title?: string | null;
};
function buildResourceOwnershipError(params: {
resourceType: 'Lesson' | 'Exercise' | 'Section';
resourceId: string;
courseId: string;
resource?: ResourceOwnershipRow;
}) {
const { resourceType, resourceId, resource } = params;
if (!resource) {
return new AppError(
`${resourceType} ${resourceId} does not exist in this course. The ID may have been hallucinated — call get_course_structure to fetch real IDs and retry.`,
'RESOURCE_NOT_IN_COURSE',
403
);
}
const titleSuffix = resource.title ? ` (${resource.title})` : '';
return new AppError(
`${resourceType} ${resourceId}${titleSuffix} belongs to a different course. Call get_course_structure to fetch IDs for the current course and retry.`,
'RESOURCE_NOT_IN_COURSE',
403
);
}
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function assertValidUuid(
resourceType: 'Lesson' | 'Exercise' | 'Section' | 'ExerciseSection',
value: string
): void {
if (!UUID_REGEX.test(value)) {
throw new AppError(
`${resourceType} id "${value}" is not a valid UUID. Call get_course_structure to fetch real IDs and try again — never invent or guess UUIDs.`,
'INVALID_RESOURCE_ID',
400
);
}
}
export async function verifyLessonBelongsToCourse(lessonId: string, courseId: string): Promise<void> {
assertValidUuid('Lesson', lessonId);
const lesson = await getLessonCourseBinding(lessonId);
if (!lesson || lesson.courseId !== courseId) {
throw buildResourceOwnershipError({
resourceType: 'Lesson',
resourceId: lessonId,
courseId,
resource: lesson
});
}
}
export async function verifyExerciseBelongsToCourse(exerciseId: string, courseId: string): Promise<void> {
assertValidUuid('Exercise', exerciseId);
const exercise = await getExerciseCourseBinding(exerciseId);
if (!exercise || exercise.courseId !== courseId) {
throw buildResourceOwnershipError({
resourceType: 'Exercise',
resourceId: exerciseId,
courseId,
resource: exercise
});
}
}
export async function verifySectionBelongsToCourse(sectionId: string, courseId: string): Promise<void> {
assertValidUuid('Section', sectionId);
const section = await getCourseSectionBinding(sectionId);
if (!section || section.courseId !== courseId) {
throw buildResourceOwnershipError({
resourceType: 'Section',
resourceId: sectionId,
courseId,
resource: section
});
}
}
type AttachedMessage = { metadata?: { attachment?: { documentId?: string } } };
export function collectDocumentIds(messages: unknown[], currentDocumentId?: string): string[] {
const ids = new Set<string>();
for (const msg of messages as AttachedMessage[]) {
const id = msg?.metadata?.attachment?.documentId;
if (id) ids.add(id);
}
if (currentDocumentId) ids.add(currentDocumentId);
return Array.from(ids);
}
export async function loadDocumentsText(documentIds: string[], userId: string): Promise<string | undefined> {
const loaded = await Promise.all(
documentIds.map(async (id) => {
const text = await getDocumentText(id, userId, redis);
return text ? { id, text } : null;
})
);
const sections = loaded
.filter((d): d is { id: string; text: string } => d !== null)
.map((d, i) => `--- Document ${i + 1} (id: ${d.id}) ---\n${d.text}`);
return sections.length > 0 ? sections.join('\n\n') : undefined;
}
type PlanMetadataMessage = {
role?: string;
metadata?: {
plan?: {
action?: string;
payload?: unknown;
};
};
};
export function getLatestImplementationPlan(messages: unknown[]): z.infer<typeof CoursePlanSchema> | undefined {
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index] as PlanMetadataMessage;
if (message?.role !== 'user') {
continue;
}
if (message?.metadata?.plan?.action !== 'implement_course_plan') {
continue;
}
const parsedPlan = CoursePlanSchema.safeParse(message.metadata.plan.payload);
if (parsedPlan.success) {
return parsedPlan.data;
}
}
return undefined;
}
+655
View File
@@ -0,0 +1,655 @@
import { tool } from 'ai';
import { CoursePlanSchema } from '@cio/ai-assistant';
import { AppError } from '@api/utils/errors';
import { trackAgentEvent, AgentEvent } from '@api/utils/tinybird';
import { getCourseContentItems } from '@cio/db/queries/course/content';
import { getExerciseSectionsByExerciseId } from '@cio/db/queries/exercise';
import type { TCourseLandingPageUpdate } from '@cio/utils/validation/course';
import { listCourseSections, createCourseSection, updateCourseSectionService } from '@api/services/course/section';
import { createLesson, getLesson, updateLessonService } from '@api/services/lesson/lesson';
import { upsertLessonLanguageService } from '@api/services/lesson-language';
import {
createExercise,
getExercise,
createExerciseSectionService,
updateExerciseService,
updateExerciseSectionMetadataService
} from '@api/services/exercise/exercise';
import { reorderCourseContent } from '@api/services/course/content';
import { normalizeAgentLessonContent } from '@api/services/agent/lesson-content';
import { buildUpdatedQuestions } from '@api/services/agent/question-update';
import { updateCourseLandingPageService } from '@api/services/course/landing-page';
import { getCourseGoLiveReadiness, publishCourseWhenReady } from '@api/services/course/go-live-readiness';
import {
assertValidUuid,
verifyExerciseBelongsToCourse,
verifyLessonBelongsToCourse,
verifySectionBelongsToCourse
} from '@api/services/agent/chat-context';
import {
addQuestionsParam,
coursePlanParam,
createExerciseParam,
createExerciseSectionParam,
createLessonParam,
createSectionParam,
emptyParam,
exerciseReadParam,
goLiveParam,
lessonReadParam,
reorderContentParam,
updateContentParam,
updateCourseLandingPageParam,
updateExerciseParam,
updateExerciseSectionParam,
updateLessonParam,
updateQuestionsParam,
updateSectionParam
} from '@api/services/agent/agent-tool-schemas';
function summarizeAgentDebugValue(value: unknown, depth = 0): unknown {
if (value == null) return value;
if (typeof value === 'string') {
return value.length > 200 ? `${value.slice(0, 200)}… (${value.length} chars)` : value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return value;
}
if (Array.isArray(value)) {
if (depth >= 2) {
return `[Array(${value.length})]`;
}
return {
count: value.length,
items: value.slice(0, 3).map((item) => summarizeAgentDebugValue(item, depth + 1))
};
}
if (typeof value === 'object') {
if (depth >= 2) {
return '[Object]';
}
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, nestedValue]) => [
key,
summarizeAgentDebugValue(nestedValue, depth + 1)
])
);
}
return String(value);
}
function logAgentToolDebug(
phase: 'start' | 'success' | 'error',
toolName: string,
details: { courseId: string; userId: string; args?: unknown; result?: unknown; error?: unknown }
) {
const base = `[agent-tool:${phase}] ${toolName}`;
if (phase === 'error') {
console.error(base, {
courseId: details.courseId,
userId: details.userId,
args: summarizeAgentDebugValue(details.args),
error:
details.error instanceof Error
? {
message: details.error.message,
stack: details.error.stack
}
: summarizeAgentDebugValue(details.error)
});
return;
}
console.info(base, {
courseId: details.courseId,
userId: details.userId,
args: summarizeAgentDebugValue(details.args),
result: summarizeAgentDebugValue(details.result)
});
}
function sanitizeToolError(toolName: string, error: unknown): Error {
if (error instanceof AppError) {
return error;
}
const raw = error instanceof Error ? error.message : String(error);
const looksLikeSqlLeak =
/^Failed query:/i.test(raw) ||
/\bparams:\s/i.test(raw) ||
/\binvalid input syntax for type uuid\b/i.test(raw) ||
/\bselect\s+.+\bfrom\s+/i.test(raw);
if (looksLikeSqlLeak) {
return new Error(
`${toolName} failed because of an invalid or unknown ID. Call get_course_structure to fetch the current IDs and retry — never invent UUIDs.`
);
}
return error instanceof Error ? error : new Error(raw);
}
async function executeAgentTool<TArgs, TResult>(
toolName: string,
params: { orgId: string; userId: string; courseId: string; args?: TArgs },
execute: () => Promise<TResult>
): Promise<TResult> {
trackAgentEvent(AgentEvent.TOOL_CALLED, {
orgId: params.orgId,
userId: params.userId,
courseId: params.courseId,
toolName
});
logAgentToolDebug('start', toolName, {
courseId: params.courseId,
userId: params.userId,
args: params.args
});
try {
const result = await execute();
trackAgentEvent(AgentEvent.TOOL_COMPLETED, {
orgId: params.orgId,
userId: params.userId,
courseId: params.courseId,
toolName,
success: true
});
logAgentToolDebug('success', toolName, {
courseId: params.courseId,
userId: params.userId,
args: params.args,
result
});
return result;
} catch (error) {
logAgentToolDebug('error', toolName, {
courseId: params.courseId,
userId: params.userId,
args: params.args,
error
});
throw sanitizeToolError(toolName, error);
}
}
export function buildAgentTools(orgId: string, userId: string, courseId: string) {
return {
get_course_structure: tool({
description:
'Get the full course structure including sections, lessons, and exercises as a tree. The courseId is automatically set — do not pass it.',
inputSchema: emptyParam,
execute: async () => {
return executeAgentTool('get_course_structure', { orgId, userId, courseId }, async () => {
const [items, sections] = await Promise.all([getCourseContentItems(courseId), listCourseSections(courseId)]);
return { sections, items };
});
}
}),
get_lesson_content: tool({
description: 'Get the HTML content of a specific lesson in this course.',
inputSchema: lessonReadParam,
execute: async (args) => {
return executeAgentTool('get_lesson_content', { orgId, userId, courseId, args }, async () => {
await verifyLessonBelongsToCourse(args.lessonId, courseId);
const lesson = await getLesson(args.lessonId);
const lessonWithLangs = lesson as {
id: string;
title: string;
lessonLanguages?: Array<{ locale: string; content: string | null }>;
};
const langContent = lessonWithLangs.lessonLanguages?.find((ll) => ll.locale === args.locale);
return { id: lesson.id, title: lesson.title, content: langContent?.content || null, locale: args.locale };
});
}
}),
get_exercise_details: tool({
description:
'Get an exercise with sections, questions, and answer options from this course. Each sections entry has an id — use it with update_exercise_section to change that question block title or description.',
inputSchema: exerciseReadParam,
execute: async (args) => {
return executeAgentTool('get_exercise_details', { orgId, userId, courseId, args }, async () => {
await verifyExerciseBelongsToCourse(args.exerciseId, courseId);
return getExercise(args.exerciseId);
});
}
}),
create_section: tool({
description: 'Create a new section in this course.',
inputSchema: createSectionParam,
execute: async (args) => {
return executeAgentTool('create_section', { orgId, userId, courseId, args }, async () => {
const section = await createCourseSection(courseId, { title: args.title, courseId, order: args.order });
return { id: section.id, title: section.title, order: section.order };
});
}
}),
update_section: tool({
description:
'Update metadata for an existing section in this course. Use this to rename a section or change its order instead of creating a new section.',
inputSchema: updateSectionParam,
execute: async (args) => {
return executeAgentTool('update_section', { orgId, userId, courseId, args }, async () => {
await verifySectionBelongsToCourse(args.sectionId, courseId);
const section = await updateCourseSectionService(args.sectionId, {
...(args.title !== undefined ? { title: args.title } : {}),
...(args.order !== undefined ? { order: args.order } : {})
});
return { id: section.id, title: section.title, updated: true };
});
}
}),
create_lesson: tool({
description:
'Create a new lesson within a section of this course. Use update_lesson_content after to add content.',
inputSchema: createLessonParam,
execute: async (args) => {
return executeAgentTool('create_lesson', { orgId, userId, courseId, args }, async () => {
await verifySectionBelongsToCourse(args.sectionId, courseId);
const lesson = await createLesson(courseId, {
title: args.title,
courseId,
sectionId: args.sectionId,
order: args.order
});
return { id: lesson.id, title: lesson.title, order: lesson.order };
});
}
}),
update_lesson: tool({
description:
'Update metadata for an existing lesson in this course. Use this to rename, move, reorder, schedule, or change visibility/unlock settings instead of creating a new lesson.',
inputSchema: updateLessonParam,
execute: async (args) => {
return executeAgentTool('update_lesson', { orgId, userId, courseId, args }, async () => {
if (args.sectionId) {
await verifySectionBelongsToCourse(args.sectionId, courseId);
}
await verifyLessonBelongsToCourse(args.lessonId, courseId);
const lesson = await updateLessonService(args.lessonId, {
...(args.title !== undefined ? { title: args.title } : {}),
...(args.sectionId !== undefined ? { sectionId: args.sectionId } : {}),
...(args.order !== undefined ? { order: args.order } : {}),
...(args.lessonAt !== undefined ? { lessonAt: args.lessonAt } : {}),
...(args.callUrl !== undefined ? { callUrl: args.callUrl } : {}),
...(args.isUnlocked !== undefined ? { isUnlocked: args.isUnlocked } : {}),
...(args.public !== undefined ? { public: args.public } : {})
});
return { id: lesson.id, title: lesson.title, updated: true };
});
}
}),
update_lesson_content: tool({
description:
'Update the text content of a lesson in this course. Replaces full lesson HTML for the given locale. For lesson HTML, put only the lesson body in the content. Do not include the lesson title. Do not use h1 or h2 anywhere in lesson HTML. Start headings at h3 because that is the highest heading level allowed in lesson content.',
inputSchema: updateContentParam,
execute: async (args) => {
return executeAgentTool('update_lesson_content', { orgId, userId, courseId, args }, async () => {
await verifyLessonBelongsToCourse(args.lessonId, courseId);
const lesson = await getLesson(args.lessonId);
const normalizedContent = normalizeAgentLessonContent(args.content, lesson.title);
await upsertLessonLanguageService(args.lessonId, {
locale: args.locale as 'en',
content: normalizedContent
});
return {
lessonId: args.lessonId,
locale: args.locale,
contentLength: normalizedContent.length,
updated: true
};
});
}
}),
create_exercise: tool({
description: 'Create a new exercise with questions and answer options in this course.',
inputSchema: createExerciseParam,
execute: async (args) => {
return executeAgentTool('create_exercise', { orgId, userId, courseId, args }, async () => {
if (args.lessonId) {
await verifyLessonBelongsToCourse(args.lessonId, courseId);
}
if (args.sectionId) {
await verifySectionBelongsToCourse(args.sectionId, courseId);
}
const exercise = await createExercise({
title: args.title,
description: args.description,
courseId,
lessonId: args.lessonId,
sectionId: args.sectionId,
order: args.order,
questions: args.questions.map((q, i) => ({
question: q.question,
questionTypeId: q.questionTypeId,
points: q.points,
order: q.order ?? i,
options: q.options.map((o) => ({ label: o.label, isCorrect: o.isCorrect }))
}))
});
return { id: exercise.id, title: exercise.title, questionCount: args.questions.length };
});
}
}),
update_exercise: tool({
description:
"Update an existing exercise's metadata (title, description, linked lesson, section, order, due date, lock state, allow-multiple-attempts). Use this for editing the exercise itself — not for changing its questions. To edit questions, use update_questions; to add questions, use add_questions.",
inputSchema: updateExerciseParam,
execute: async (args) => {
return executeAgentTool('update_exercise', { orgId, userId, courseId, args }, async () => {
await verifyExerciseBelongsToCourse(args.exerciseId, courseId);
if (args.lessonId) {
await verifyLessonBelongsToCourse(args.lessonId, courseId);
}
if (args.sectionId) {
await verifySectionBelongsToCourse(args.sectionId, courseId);
}
const updated = await updateExerciseService(args.exerciseId, {
title: args.title,
description: args.description,
lessonId: args.lessonId,
sectionId: args.sectionId,
order: args.order,
dueBy: args.dueBy,
isUnlocked: args.isUnlocked,
allowMultipleAttempts: args.allowMultipleAttempts
});
return {
id: updated.id,
title: updated.title,
description: updated.description ?? null,
dueBy: updated.dueBy ?? null,
updated: true
};
});
}
}),
update_exercise_section: tool({
description:
'Update the title and/or description of one section inside an exercise (question groups). This is not the same as update_section, which edits course outline sections. Always use ids from get_exercise_details `sections` for this tool.',
inputSchema: updateExerciseSectionParam,
execute: async (args) => {
return executeAgentTool('update_exercise_section', { orgId, userId, courseId, args }, async () => {
assertValidUuid('Exercise', args.exerciseId);
assertValidUuid('ExerciseSection', args.exerciseSectionId);
await verifyExerciseBelongsToCourse(args.exerciseId, courseId);
const updated = await updateExerciseSectionMetadataService(args.exerciseId, args.exerciseSectionId, {
...(args.title !== undefined ? { title: args.title } : {}),
...(args.description !== undefined ? { description: args.description } : {})
});
return { ...updated, updated: true };
});
}
}),
create_exercise_section: tool({
description:
'Create a new section inside an existing exercise. Use this to add a question block before adding or moving questions into it.',
inputSchema: createExerciseSectionParam,
execute: async (args) => {
return executeAgentTool('create_exercise_section', { orgId, userId, courseId, args }, async () => {
assertValidUuid('Exercise', args.exerciseId);
await verifyExerciseBelongsToCourse(args.exerciseId, courseId);
const created = await createExerciseSectionService(args.exerciseId, {
title: args.title,
description: args.description,
order: args.order,
colorTheme: args.colorTheme,
afterBehavior: args.afterBehavior
});
return { ...created, created: true };
});
}
}),
add_questions: tool({
description:
'Add questions to an existing exercise in this course. When get_exercise_details lists in-exercise sections, pass exerciseSectionId so new questions are added to the correct block.',
inputSchema: addQuestionsParam,
execute: async (args) => {
return executeAgentTool('add_questions', { orgId, userId, courseId, args }, async () => {
await verifyExerciseBelongsToCourse(args.exerciseId, courseId);
if (args.exerciseSectionId !== undefined) {
assertValidUuid('ExerciseSection', args.exerciseSectionId);
const sections = await getExerciseSectionsByExerciseId(args.exerciseId);
const belongs = sections.some((section) => section.id === args.exerciseSectionId);
if (!belongs) {
throw new AppError(
'That exercise section was not found on this exercise. Call get_exercise_details and use a section id from the sections array.',
'VALIDATION_ERROR',
404
);
}
}
const existingExercise = await getExercise(args.exerciseId);
const existingQuestions = existingExercise.questions || [];
const nextOrder = existingQuestions.length;
const newQuestions = args.questions.map((q, i) => ({
question: q.question,
questionTypeId: q.questionTypeId,
points: q.points,
order: q.order ?? nextOrder + i,
...(args.exerciseSectionId !== undefined ? { exerciseSectionId: args.exerciseSectionId } : {}),
options: q.options.map((o) => ({ label: o.label, isCorrect: o.isCorrect }))
}));
const allQuestions = [
...existingQuestions.map((eq) => ({
id: Number(eq.id),
question: eq.title,
questionTypeId: eq.questionTypeId,
points: eq.points,
order: eq.order,
options: eq.options.map((o) => ({ id: Number(o.id), label: o.label || '', isCorrect: o.isCorrect }))
})),
...newQuestions
];
await updateExerciseService(args.exerciseId, { questions: allQuestions });
return { exerciseId: args.exerciseId, addedCount: args.questions.length, totalCount: allQuestions.length };
});
}
}),
update_questions: tool({
description:
'Update existing questions in an exercise. Pass only fields you want to change; `id` identifies the question. Optional `exerciseSectionId` moves the question to another in-exercise block (use get_exercise_details section ids), or null to unassign. For NUMERIC, the correct answer is `settings.correctValue` (number) — do NOT add options to NUMERIC questions. For STAR use `settings.correctValue`. For WORD_BANK use `settings.correctAnswers` and `settings.template`. RADIO/CHECKBOX/TRUE_FALSE use `options[].isCorrect` (include option `id` to edit, omit `id` to add). `settings` is shallow-merged with existing settings. Omit `options` entirely to leave existing options untouched.',
inputSchema: updateQuestionsParam,
execute: async (args) => {
return executeAgentTool('update_questions', { orgId, userId, courseId, args }, async () => {
await verifyExerciseBelongsToCourse(args.exerciseId, courseId);
const sectionIdsInPatches = new Set<string>();
for (const patch of args.questions) {
if (patch.exerciseSectionId === undefined || patch.exerciseSectionId === null) {
continue;
}
assertValidUuid('ExerciseSection', patch.exerciseSectionId);
sectionIdsInPatches.add(patch.exerciseSectionId);
}
if (sectionIdsInPatches.size > 0) {
const sections = await getExerciseSectionsByExerciseId(args.exerciseId);
const validIds = new Set(sections.map((section) => section.id));
for (const sectionId of sectionIdsInPatches) {
if (!validIds.has(sectionId)) {
throw new AppError(
'That exercise section was not found on this exercise. Call get_exercise_details and use a section id from the sections array.',
'VALIDATION_ERROR',
404
);
}
}
}
const existing = await getExercise(args.exerciseId);
const merged = buildUpdatedQuestions(
(existing.questions || []).map((q) => ({
id: q.id,
title: q.title,
questionTypeId: q.questionTypeId,
points: q.points,
order: q.order,
exerciseSectionId: q.exerciseSectionId,
settings: q.settings,
options: q.options
})),
args.questions,
args.exerciseId
);
await updateExerciseService(args.exerciseId, { questions: merged });
return { exerciseId: args.exerciseId, updatedCount: merged.length };
});
}
}),
reorder_content: tool({
description:
'Reorder sections, lessons, or exercises in this course. Can change the order of sections, change the order of lessons/exercises within a section, or move items between sections. Use get_course_structure first to see current order, and copy item IDs exactly from that response. Never rewrite or guess UUIDs.',
inputSchema: reorderContentParam,
execute: async (args) => {
return executeAgentTool('reorder_content', { orgId, userId, courseId, args }, async () => {
if (args.sections) {
for (const section of args.sections) {
await verifySectionBelongsToCourse(section.id, courseId);
}
}
if (args.items) {
for (const item of args.items) {
if (item.type === 'LESSON') {
await verifyLessonBelongsToCourse(item.id, courseId);
} else {
await verifyExerciseBelongsToCourse(item.id, courseId);
}
if (item.sectionId) {
await verifySectionBelongsToCourse(item.sectionId, courseId);
}
}
}
return reorderCourseContent(courseId, {
sections: args.sections,
items: args.items
});
});
}
}),
update_course_landing_page: tool({
description:
'Update course-level landing page fields for this course, including public title, description, overview, goals, requirements, instructor metadata, pricing, and banner image. The courseId is automatically set — do not pass it.',
inputSchema: updateCourseLandingPageParam,
execute: async (args) => {
return executeAgentTool('update_course_landing_page', { orgId, userId, courseId, args }, async () => {
const result = await updateCourseLandingPageService(courseId, args as TCourseLandingPageUpdate);
return {
courseId: result.course.id,
title: result.course.title,
description: result.course.description,
courseUrl: result.courseUrl,
bannerImageUrl: result.bannerImageUrl,
updated: true
};
});
}
}),
check_course_go_live_readiness: tool({
description:
'Check whether this course is ready to go live. Returns blockers, warnings, suggested fixes, and the public course URL if available. This tool does not publish the course.',
inputSchema: emptyParam,
execute: async () => {
return executeAgentTool('check_course_go_live_readiness', { orgId, userId, courseId }, async () => {
return getCourseGoLiveReadiness(courseId);
});
}
}),
go_live_course: tool({
description:
'Publish this course only after the teacher explicitly asks to go live. Runs the readiness checklist first, generates a slug when needed, and fails with blockers if the course is not ready.',
inputSchema: goLiveParam,
execute: async (args) => {
return executeAgentTool('go_live_course', { orgId, userId, courseId, args }, async () => {
const result = await publishCourseWhenReady(courseId);
return {
courseId: result.course.id,
title: result.course.title,
slug: result.course.slug,
isPublished: result.course.isPublished,
readiness: result.readiness
};
});
}
}),
generate_course_plan: tool({
description:
'Generate a structured course plan with sections and lessons. Always use this when asked to design or plan a course.',
inputSchema: coursePlanParam,
execute: async (args) => {
return executeAgentTool('generate_course_plan', { orgId, userId, courseId, args }, async () => {
const envelope = coursePlanParam.parse(args);
const plan = CoursePlanSchema.parse(envelope.plan);
const sectionCount = plan.sections.length;
const itemCount = plan.sections.reduce((sum, section) => sum + section.items.length, 0);
trackAgentEvent(AgentEvent.PLAN_GENERATED, { orgId, userId, courseId, sectionCount, itemCount });
return plan;
});
}
})
};
}
+25 -21
View File
@@ -1,27 +1,31 @@
/**
* Trims conversation message history to stay within token budget.
*
* Strategy:
* - Always keep the first message (often contains the initial request/document context)
* - Always keep the last N messages (recent context is most relevant)
* - Drop middle messages when the array is too long
*
* This is a message-count heuristic, not a token counter. Each message averages
* ~200-500 tokens, so capping at ~40 messages keeps us under ~20K tokens of
* history, leaving room for the system prompt + document text + LLM response.
*/
const MAX_MESSAGES = 20;
const KEEP_RECENT = 16;
const MAX_TOOL_OUTPUT_CHARS = 2000;
const MAX_MESSAGES = 40;
const KEEP_RECENT = 30;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function trimToolOutputs(messages: any[]): any[] {
return messages.map((message) => {
if (!Array.isArray(message.parts)) return message;
const trimmedParts = message.parts.map((part: any) => {
if (part.state !== 'output-available' || part.output == null) return part;
const serialized = typeof part.output === 'string' ? part.output : JSON.stringify(part.output);
if (serialized.length <= MAX_TOOL_OUTPUT_CHARS) return part;
const truncated = serialized.slice(0, MAX_TOOL_OUTPUT_CHARS) + '…[truncated]';
return { ...part, output: truncated };
});
if (trimmedParts === message.parts) return message;
return { ...message, parts: trimmedParts };
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function trimMessageHistory(messages: any[]): any[] {
if (messages.length <= MAX_MESSAGES) {
return messages;
}
const countTrimmed = messages.length <= MAX_MESSAGES ? messages : [messages[0], ...messages.slice(-KEEP_RECENT)];
const firstMessage = messages[0];
const recentMessages = messages.slice(-KEEP_RECENT);
return [firstMessage, ...recentMessages];
return trimToolOutputs(countTrimmed);
}
@@ -0,0 +1,21 @@
import { findCreditPurchaseByOrderId, insertCreditPurchaseAndApplyBalance } from '@cio/db/queries/agent';
import type { CreditPurchaseInput } from '@cio/db/queries/agent';
import { AppError } from '@api/utils/errors';
/**
* Record a Polar token-pack purchase.
* Idempotent on `providerOrderId` — Polar may retry the webhook.
*/
export async function recordCreditPurchase(input: CreditPurchaseInput) {
if (input.tokens <= 0) {
throw new AppError('Tokens must be positive', 'INVALID_CREDIT_AMOUNT', 400);
}
const existing = await findCreditPurchaseByOrderId(input.providerOrderId);
if (existing) {
return existing;
}
return insertCreditPurchaseAndApplyBalance(input);
}
+7 -3
View File
@@ -107,6 +107,7 @@ export async function parseAndStoreDocument(
text: extractedText,
fileName: file.name,
mimeType,
userId,
uploadedAt: new Date().toISOString()
}),
{ EX: DOCUMENT_REDIS_TTL }
@@ -150,16 +151,18 @@ export async function parseAndStoreDocument(
* Retrieve stored document text. Tries the Redis hot cache first; on miss
* falls back to Postgres and rehydrates Redis for next time.
*/
export async function getDocumentText(documentId: string, redis: RedisClient): Promise<string | null> {
export async function getDocumentText(documentId: string, userId: string, redis: RedisClient): Promise<string | null> {
const raw = await redis.get(agentDocumentKey(documentId));
if (raw) {
const parsed = JSON.parse(raw) as { text: string };
const parsed = JSON.parse(raw) as { text: string; userId?: string };
if (parsed.userId && parsed.userId !== userId) return null;
return parsed.text;
}
const record = await getChatDocument(documentId);
const record = await getChatDocument(documentId, userId);
if (!record) return null;
@@ -169,6 +172,7 @@ export async function getDocumentText(documentId: string, redis: RedisClient): P
text: record.text,
fileName: record.fileName,
mimeType: record.mimeType,
userId: record.userId,
uploadedAt: record.createdAt
}),
{ EX: DOCUMENT_REDIS_TTL }
+10 -1
View File
@@ -6,6 +6,7 @@ export type CurrentQuestionForPatch = {
questionTypeId: number;
points: number;
order: number;
exerciseSectionId?: string | null;
settings?: Record<string, unknown>;
options: Array<{
id: number | string;
@@ -21,6 +22,7 @@ export type QuestionPatch = {
questionTypeId?: number;
points?: number;
order?: number;
exerciseSectionId?: string | null;
settings?: Record<string, unknown>;
options?: Array<{
id?: number;
@@ -43,6 +45,7 @@ export type MergedQuestion = {
questionTypeId: number;
points: number;
order: number;
exerciseSectionId?: string | null;
settings?: Record<string, unknown>;
options: MergedOption[];
};
@@ -97,7 +100,7 @@ export function buildUpdatedQuestions(
});
}
return {
const merged: MergedQuestion = {
id: patch.id,
question: patch.question ?? current.title ?? '',
questionTypeId: patch.questionTypeId ?? current.questionTypeId,
@@ -106,5 +109,11 @@ export function buildUpdatedQuestions(
settings: mergedSettings,
options: mergedOptions
};
if (patch.exerciseSectionId !== undefined) {
merged.exerciseSectionId = patch.exerciseSectionId;
}
return merged;
});
}
+61 -10
View File
@@ -1,4 +1,5 @@
import { generateText } from 'ai';
import { generateText, generateObject } from 'ai';
import { z } from 'zod';
import { AIProvider, type AIProviderConfig } from '@cio/ai-assistant';
import { createOpenAI } from '@ai-sdk/openai';
import { createAnthropic } from '@ai-sdk/anthropic';
@@ -9,8 +10,9 @@ import { createGoogleGenerativeAI } from '@ai-sdk/google';
*/
const TITLE_MODELS: Record<AIProvider, string> = {
[AIProvider.OPENAI]: 'gpt-4o-mini',
[AIProvider.ANTHROPIC]: 'claude-haiku-4-20250414',
[AIProvider.GOOGLE]: 'gemini-2.5-flash'
[AIProvider.ANTHROPIC]: 'claude-haiku-4-5-20251001',
[AIProvider.GOOGLE]: 'gemini-2.5-flash',
[AIProvider.MOONSHOT]: 'kimi-k2.6'
};
function createTitleModel(config: AIProviderConfig) {
@@ -29,6 +31,10 @@ function createTitleModel(config: AIProviderConfig) {
const google = createGoogleGenerativeAI({ apiKey: config.apiKey });
return google(modelName);
}
case AIProvider.MOONSHOT: {
const moonshot = createOpenAI({ apiKey: config.apiKey, baseURL: 'https://api.moonshot.cn/v1' });
return moonshot(modelName);
}
default:
throw new Error(`Unsupported AI provider: ${config.provider}`);
}
@@ -44,13 +50,58 @@ export async function generateConversationTitle(
): Promise<string> {
const model = createTitleModel(providerConfig);
const { text } = await generateText({
model,
system:
"Generate a short title (max 6 words) for a chat conversation based on the user's first message. Return ONLY the title, no quotes, no punctuation at the end. Be concise and descriptive.",
prompt: firstMessageText,
maxOutputTokens: 30
});
const systemPrompt =
"Generate a short, descriptive title (2–5 words) for a chat conversation based on the user's first message. The title must be at least 2 words — never a single word. Return ONLY the title, no quotes, no punctuation at the end.";
const truncatedMessage = firstMessageText.slice(0, 1000);
// Gemini 2.5 counts thinking toward maxOutputTokens; disable thinking and allow headroom for the title text.
const { text } = await generateText(
providerConfig.provider === AIProvider.GOOGLE
? {
model,
system: systemPrompt,
prompt: truncatedMessage,
maxOutputTokens: 256,
maxRetries: 0,
providerOptions: {
google: { thinkingConfig: { thinkingBudget: 0 } }
}
}
: {
model,
system: systemPrompt,
prompt: truncatedMessage,
maxOutputTokens: 96,
maxRetries: 0
}
);
return text.trim().slice(0, 80);
}
const CourseMeta = z.object({
title: z.string().describe('3–6 word course title, no quotes, no trailing punctuation'),
description: z.string().describe('20–30 word description of what students will learn')
});
export async function generateCourseMeta(
prompt: string,
providerConfig: AIProviderConfig
): Promise<{ title: string; description: string }> {
const model = createTitleModel(providerConfig);
const { object } = await generateObject({
model,
schema: CourseMeta,
system:
"You are helping create an online course. Based on the user's course idea, generate a short title (3–6 words, no quotes, no trailing punctuation) and a concise description (20–30 words) explaining what students will learn.",
prompt: prompt.slice(0, 1000),
maxRetries: 0
});
return {
title: object.title.trim().slice(0, 100),
description: object.description.trim().slice(0, 300)
};
}
+124 -108
View File
@@ -1,22 +1,51 @@
import { db } from '@cio/db';
import * as schema from '@cio/db/schema';
import { eq, and, gte, sql } from 'drizzle-orm';
import { getActiveOrganizationPlan } from '@cio/db/queries/organization';
import { AppError, ErrorCodes } from '@api/utils/errors';
import type { TokenBalance, TokenUsage } from '@cio/ai-assistant';
import {
aggregateTokenUsageByUser,
countRequests,
getDailyTokenUsageHistory,
getMonthlyTokenUsage,
getOrgCreditBalance,
insertTokenUsageAndDrainCredits,
summarizePurchases,
upsertCreditBalance
} from '@cio/db/queries/agent';
import type { UsageLeaderboardRow } from '@cio/db/queries/agent';
import { getActiveOrganizationPlan } from '@cio/db/queries/organization';
import { AppError } from '@api/utils/errors';
function startOfCurrentMonth(): Date {
const date = new Date();
date.setDate(1);
date.setHours(0, 0, 0, 0);
return date;
}
// ─── Plan-Based Token Allowances ─────────────────────────────────────────────
const PLAN_TOKEN_ALLOWANCES: Record<string, number> = {
BASIC: 50_000,
EARLY_ADOPTER: 500_000,
ENTERPRISE: 2_000_000
BASIC: 500_000,
EARLY_ADOPTER: 3_000_000,
ENTERPRISE: 15_000_000
};
/**
* Get the monthly token allowance for an org based on its active plan.
* Also checks if the plan payload has a custom `aiTokenAllowance` override.
*/
// ─── Model Cost Multipliers ───────────────────────────────────────────────────
// Blended multiplier vs Gemini 2.5 Flash baseline ($0.15/1M); ~80/20 input/output mix; unknown → 1×.
const MODEL_COST_MULTIPLIER: Record<string, number> = {
'gemini-2.5-flash': 1,
'gpt-5.4-mini': 3,
'claude-sonnet-4-6': 11,
'claude-haiku-4-5-20251001': 1,
'kimi-k2.6': 3
};
function computeCostUnits(promptTokens: number, completionTokens: number, model: string): number {
const multiplier = MODEL_COST_MULTIPLIER[model] ?? 1;
return Math.round((promptTokens + completionTokens) * multiplier);
}
async function getPlanAllowance(orgId: string): Promise<{ planName: string; allowance: number }> {
const activePlan = await getActiveOrganizationPlan(orgId);
@@ -32,42 +61,16 @@ async function getPlanAllowance(orgId: string): Promise<{ planName: string; allo
return { planName, allowance };
}
/**
* Get the total tokens used by an org in the current month.
*/
async function getMonthlyUsage(orgId: string): Promise<number> {
const startOfMonth = new Date();
startOfMonth.setDate(1);
startOfMonth.setHours(0, 0, 0, 0);
const [result] = await db
.select({
totalTokens: sql<number>`COALESCE(SUM(${schema.aiTokenUsage.promptTokens} + ${schema.aiTokenUsage.completionTokens}), 0)`
})
.from(schema.aiTokenUsage)
.where(and(eq(schema.aiTokenUsage.orgId, orgId), gte(schema.aiTokenUsage.createdAt, startOfMonth.toISOString())));
return Number(result?.totalTokens ?? 0);
export async function getMonthlyUsage(orgId: string): Promise<number> {
return getMonthlyTokenUsage(orgId, startOfCurrentMonth());
}
/**
* Get the credit balance for an org.
*/
async function getCreditBalance(orgId: string): Promise<number> {
const [result] = await db
.select({ balance: schema.aiCreditBalance.balance })
.from(schema.aiCreditBalance)
.where(eq(schema.aiCreditBalance.orgId, orgId))
.limit(1);
return result?.balance ?? 0;
export async function getCreditBalance(orgId: string): Promise<number> {
return getOrgCreditBalance(orgId);
}
/**
* Get the full token balance for an org: used, allowance, credits, remaining.
*/
export async function getTokenBalance(orgId: string): Promise<TokenBalance> {
const [{ planName, allowance }, monthlyUsage, creditBalance] = await Promise.all([
const [{ allowance }, monthlyUsage, creditBalance] = await Promise.all([
getPlanAllowance(orgId),
getMonthlyUsage(orgId),
getCreditBalance(orgId)
@@ -84,9 +87,6 @@ export async function getTokenBalance(orgId: string): Promise<TokenBalance> {
};
}
/**
* Check if the org has sufficient tokens. Throws 402 if exhausted.
*/
export async function enforceTokenBalance(orgId: string): Promise<TokenBalance> {
const balance = await getTokenBalance(orgId);
@@ -97,9 +97,17 @@ export async function enforceTokenBalance(orgId: string): Promise<TokenBalance>
return balance;
}
/**
* Record token usage after an LLM call.
*/
export function computePurchasedTokenOverflow(params: {
allowance: number;
monthlyUsageBefore: number;
requestTokens: number;
}): number {
const allowanceRemainingBefore = Math.max(0, params.allowance - params.monthlyUsageBefore);
return Math.max(0, params.requestTokens - allowanceRemainingBefore);
}
/** Record token usage after an LLM call; drains purchased credits when usage exceeds plan allowance. */
export async function recordTokenUsage(
orgId: string,
userId: string,
@@ -107,29 +115,28 @@ export async function recordTokenUsage(
usage: TokenUsage,
model: string
): Promise<void> {
await db.insert(schema.aiTokenUsage).values({
const costUnits = computeCostUnits(usage.promptTokens, usage.completionTokens, model);
const { allowance } = await getPlanAllowance(orgId);
await insertTokenUsageAndDrainCredits({
orgId,
userId,
courseId,
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
model
costUnits,
model,
planAllowance: allowance,
since: startOfCurrentMonth()
});
}
/**
* Check if an org's plan supports a specific feature (e.g., document upload).
* Returns the plan name so callers can decide what to gate.
*/
export async function getOrgPlanName(orgId: string): Promise<string> {
const { planName } = await getPlanAllowance(orgId);
return planName;
}
/**
* Check if an org is on a paid plan (EARLY_ADOPTER or above).
*/
export async function isOrgOnPaidPlan(orgId: string): Promise<boolean> {
const planName = await getOrgPlanName(orgId);
@@ -143,67 +150,76 @@ export interface DailyUsage {
tokens: number;
}
/**
* Get daily token usage for the current month, grouped by day.
*/
export async function getUsageHistory(orgId: string): Promise<DailyUsage[]> {
const startOfMonth = new Date();
startOfMonth.setDate(1);
startOfMonth.setHours(0, 0, 0, 0);
const rows = await db
.select({
date: sql<string>`DATE(${schema.aiTokenUsage.createdAt})`,
tokens: sql<number>`SUM(${schema.aiTokenUsage.promptTokens} + ${schema.aiTokenUsage.completionTokens})`
})
.from(schema.aiTokenUsage)
.where(and(eq(schema.aiTokenUsage.orgId, orgId), gte(schema.aiTokenUsage.createdAt, startOfMonth.toISOString())))
.groupBy(sql`DATE(${schema.aiTokenUsage.createdAt})`)
.orderBy(sql`DATE(${schema.aiTokenUsage.createdAt})`);
return rows.map((row) => ({
date: String(row.date),
tokens: Number(row.tokens)
}));
return getDailyTokenUsageHistory(orgId, startOfCurrentMonth());
}
/**
* Get full detailed usage stats for the usage endpoint.
*/
export async function getDetailedUsage(orgId: string) {
const [balance, history] = await Promise.all([getTokenBalance(orgId), getUsageHistory(orgId)]);
export interface PurchasedSummary {
totalPurchasedTokens: number;
totalSpentCents: number;
currency: 'USD';
currentBalance: number;
lastPurchaseAt: string | null;
}
return { ...balance, history };
export async function getPurchasedSummary(orgId: string): Promise<PurchasedSummary> {
const [summary, currentBalance] = await Promise.all([summarizePurchases(orgId), getCreditBalance(orgId)]);
return {
totalPurchasedTokens: summary.totalPurchasedTokens,
totalSpentCents: summary.totalSpentCents,
currency: 'USD',
currentBalance,
lastPurchaseAt: summary.lastPurchaseAt
};
}
export interface LeaderboardEntry {
userId: string;
fullname: string | null;
email: string | null;
avatarUrl: string | null;
tokens: number;
requests: number;
favoriteModel: string | null;
percentage: number;
}
export async function getTeamLeaderboard(orgId: string): Promise<{ entries: LeaderboardEntry[]; totalTokens: number }> {
const rows: UsageLeaderboardRow[] = await aggregateTokenUsageByUser(orgId, startOfCurrentMonth());
const totalTokens = rows.reduce((sum: number, row: UsageLeaderboardRow) => sum + row.tokens, 0);
const entries: LeaderboardEntry[] = rows.map((row: UsageLeaderboardRow) => ({
userId: row.userId,
fullname: row.fullname,
email: row.email,
avatarUrl: row.avatarUrl,
tokens: row.tokens,
requests: row.requests,
favoriteModel: row.favoriteModel,
percentage: totalTokens > 0 ? row.tokens / totalTokens : 0
}));
return { entries, totalTokens };
}
export async function getDetailedUsage(orgId: string) {
const start = startOfCurrentMonth();
const [balance, history, requestsThisMonth] = await Promise.all([
getTokenBalance(orgId),
getDailyTokenUsageHistory(orgId, start),
countRequests(orgId, start)
]);
return { ...balance, history, requestsThisMonth };
}
// ─── Credit Purchases ────────────────────────────────────────────────────────
/**
* Add credits to an org's balance.
* Creates the balance row if it doesn't exist (upsert).
*/
export async function addCredits(orgId: string, amount: number): Promise<number> {
if (amount <= 0) {
throw new AppError('Credit amount must be positive', 'INVALID_CREDIT_AMOUNT', 400);
}
const existing = await db
.select({ id: schema.aiCreditBalance.id, balance: schema.aiCreditBalance.balance })
.from(schema.aiCreditBalance)
.where(eq(schema.aiCreditBalance.orgId, orgId))
.limit(1);
if (existing.length > 0) {
const newBalance = existing[0].balance + amount;
await db
.update(schema.aiCreditBalance)
.set({ balance: newBalance, updatedAt: new Date().toISOString() })
.where(eq(schema.aiCreditBalance.orgId, orgId));
return newBalance;
}
await db.insert(schema.aiCreditBalance).values({ orgId, balance: amount });
return amount;
return upsertCreditBalance(orgId, amount);
}
@@ -27,7 +27,7 @@ import {
getCourseImportDraftByIdempotencyKey,
updateCourseImportDraft
} from '@cio/db/queries/course-import';
import { getCourseById } from '@cio/db/queries/course';
import { getCourseById, isCourseSlugTaken, updateCourseSlug } from '@cio/db/queries/course';
import { getLessonLanguagesByLessonIds } from '@cio/db/queries/lesson';
import { getOrganizationById } from '@cio/db/queries/organization';
import { getCourseTagsForOrganization } from '@cio/db/queries/tag';
@@ -45,10 +45,7 @@ import {
} from '@cio/utils/validation/course-import';
import { getCourseOrganizationId } from '@cio/db/queries/tag';
import { assignCourseTagsByName } from '@api/services/tag';
import { db } from '@cio/db/drizzle';
import * as schema from '@cio/db/schema';
import { generateSlug } from '@cio/utils/functions';
import { eq } from 'drizzle-orm';
type DraftSummary = {
sectionCount: number;
@@ -104,13 +101,8 @@ 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;
}
@@ -127,8 +119,13 @@ async function ensureCourseSlug(courseId: string, title: string | null | undefin
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;
}
function buildCourseBaseUrl(organization: TOrganization) {
+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);
}
+363 -31
View File
@@ -6,6 +6,7 @@ import { getLessonById } from '@cio/db/queries/lesson';
import {
createExercises,
syncOptionIdSequence,
syncQuestionIdSequence,
createOptions,
createQuestions,
deleteExercise,
@@ -14,14 +15,20 @@ import {
getExerciseById,
getExerciseCompletionByProfile,
getExerciseCompletionsByProfile,
getExerciseSectionsByExerciseId,
getExerciseWithRelationsOptimized,
getExercisesByCourseId,
getLMSExercises,
createExerciseSections,
deleteExerciseSectionsByIds,
updateExerciseSection,
updateExercise,
updateOptions,
updateQuestions
} from '@cio/db/queries/exercise';
import { touchCourseUpdatedAt } from '@cio/db/queries/course';
import { resolveItemSlug } from '@api/services/course/slug';
import { guardNonAutoGradableQuestionsForCourseType } from '@api/services/course/public-course-guard';
import { db } from '@cio/db/drizzle';
import type { DbOrTxClient } from '@cio/db/drizzle';
@@ -37,6 +44,17 @@ import {
type ExerciseWithQuestions = TExercise & {
isComplete: boolean;
questions?: QuestionWithRelations[];
sections?: ExerciseSectionWithQuestions[];
};
type ExerciseSectionWithQuestions = {
id: string;
title: string;
description: string | null;
order: number;
colorTheme: string;
afterBehavior: unknown;
questions: QuestionWithRelations[];
};
type ExerciseWithCompletion = TExercise & {
@@ -62,10 +80,126 @@ function sanitizeExercisePayload(data: TExerciseCreate | TExerciseUpdate): TExer
return {
...data,
description: sanitizeOptionalHtml(data.description),
sections:
'sections' in data
? data.sections?.map((section) => ({
...section,
title: sanitizeHtml(section.title),
description: sanitizeOptionalHtml(section.description ?? undefined)
}))
: undefined,
questions: sanitizeExerciseQuestions(data.questions)
};
}
function groupQuestionsBySections(
sections: Awaited<ReturnType<typeof getExerciseSectionsByExerciseId>>,
questions: QuestionWithRelations[]
) {
const questionsBySectionId = new Map<string, QuestionWithRelations[]>();
const unsectionedQuestions: QuestionWithRelations[] = [];
for (const question of questions) {
if (question.exerciseSectionId) {
const sectionQuestions = questionsBySectionId.get(question.exerciseSectionId) ?? [];
sectionQuestions.push(question);
questionsBySectionId.set(question.exerciseSectionId, sectionQuestions);
continue;
}
unsectionedQuestions.push(question);
}
return {
sections: sections.map((section) => ({
id: section.id,
title: section.title,
description: section.description,
order: section.order,
colorTheme: section.colorTheme,
afterBehavior: section.afterBehavior,
questions: questionsBySectionId.get(section.id) ?? []
})),
unsectionedQuestions
};
}
async function syncExerciseSections(
exerciseId: string,
sections: NonNullable<TExerciseUpdate['sections']>,
txClient: DbOrTxClient
) {
const currentSections = await getExerciseSectionsByExerciseId(exerciseId, txClient);
const currentSectionIds = new Set(currentSections.map((section) => section.id));
const incomingSectionIds = new Set(sections.map((section) => section.id).filter((id): id is string => !!id));
for (const section of sections) {
if (section.afterBehavior.action !== 'go_to_section') continue;
if (!incomingSectionIds.has(section.afterBehavior.exerciseSectionId)) {
throw new AppError('Section routing target must exist in this exercise', ErrorCodes.VALIDATION_ERROR, 400);
}
}
const sectionsToDelete = currentSections
.filter((section) => !incomingSectionIds.has(section.id))
.map((section) => section.id);
if (sectionsToDelete.length > 0) {
await deleteExerciseSectionsByIds(sectionsToDelete, txClient);
}
const sectionsToCreate = sections
.filter((section) => !section.id || !currentSectionIds.has(section.id))
.map((section) => ({
id: section.id,
exerciseId,
title: section.title,
description: section.description ?? null,
order: section.order,
colorTheme: section.colorTheme,
afterBehavior: section.afterBehavior
}));
if (sectionsToCreate.length > 0) {
await createExerciseSections(sectionsToCreate, txClient);
}
await Promise.all(
sections
.filter((section) => section.id && currentSectionIds.has(section.id))
.map((section) =>
updateExerciseSection(
section.id!,
{
title: section.title,
description: section.description ?? null,
order: section.order,
colorTheme: section.colorTheme,
afterBehavior: section.afterBehavior
},
txClient
)
)
);
}
function validateSectionQuestionAssignments(data: TExerciseUpdate) {
if (!data.sections || data.sections.length === 0 || !data.questions) return;
const sectionIds = new Set(data.sections.map((section) => section.id).filter((id): id is string => !!id));
const unassignedQuestion = data.questions.find(
(question) => !question.deletedAt && (!question.exerciseSectionId || !sectionIds.has(question.exerciseSectionId))
);
if (unassignedQuestion) {
throw new AppError(
'All active questions must be assigned to an exercise section',
ErrorCodes.VALIDATION_ERROR,
400
);
}
}
async function resolveExerciseCourseId(exercise: TExercise): Promise<string | null> {
if (exercise.courseId) {
return exercise.courseId;
@@ -89,6 +223,17 @@ export async function createExercise(data: TExerciseCreate): Promise<TExercise>
try {
const sanitizedData = sanitizeExercisePayload(data);
await guardNonAutoGradableQuestionsForCourseType({
courseId: sanitizedData.courseId,
questionTypeIds: (sanitizedData.questions ?? []).map((question) => question.questionTypeId)
});
const slug = await resolveItemSlug({
courseId: sanitizedData.courseId,
title: sanitizedData.title,
requestedSlug: sanitizedData.slug
});
const exerciseData = {
title: sanitizedData.title,
description: sanitizedData.description ?? null,
@@ -96,7 +241,8 @@ export async function createExercise(data: TExerciseCreate): Promise<TExercise>
courseId: sanitizedData.courseId,
sectionId: sanitizedData.sectionId || null,
order: sanitizedData.order ?? null,
dueBy: sanitizedData.dueBy || null
dueBy: sanitizedData.dueBy || null,
slug
};
const [exercise] = await createExercises([exerciseData]);
@@ -118,23 +264,26 @@ export async function createExercise(data: TExerciseCreate): Promise<TExercise>
}));
const questions = await createQuestions(questionsData, txClient);
const hasOptions = sanitizedData.questions!.some((question) => (question.options?.length ?? 0) > 0);
if (hasOptions) {
await syncOptionIdSequence(txClient);
}
// Create options for each question
const optionsPromises = sanitizedData.questions!.map(async (q, index) => {
for (const [index, questionData] of sanitizedData.questions!.entries()) {
const question = questions[index];
if (!question) return;
if (!question) continue;
const optionsData: TNewOption[] = (q.options ?? []).map((opt) => ({
const optionsData: TNewOption[] = (questionData.options ?? []).map((opt) => ({
questionId: question.id,
label: opt.label,
isCorrect: opt.isCorrect,
settings: opt.settings ?? {}
}));
return createOptions(optionsData, txClient);
});
await Promise.all(optionsPromises);
await createOptions(optionsData, txClient);
}
});
}
@@ -185,11 +334,14 @@ export async function getExercise(
// Transform questions (strip questionType and options since they're handled in transform)
const questionsForTransform = questions.map(({ questionType, options, ...q }) => q);
const questionsWithOptions = transformQuestions(questionsForTransform, allOptions, questionTypeMap);
const sections = await getExerciseSectionsByExerciseId(exerciseId, dbClient);
const sectionGroups = groupQuestionsBySections(sections, questionsWithOptions);
return {
...exercise,
isComplete,
questions: questionsWithOptions
questions: questionsWithOptions,
sections: sectionGroups.sections
};
} catch (error) {
if (error instanceof AppError) {
@@ -214,9 +366,28 @@ export async function getExercise(
export async function updateExerciseService(exerciseId: string, data: TExerciseUpdate): Promise<TExercise> {
try {
const sanitizedData = sanitizeExercisePayload(data);
validateSectionQuestionAssignments(sanitizedData);
const existing = await getExerciseById(exerciseId);
if (existing?.courseId) {
await guardNonAutoGradableQuestionsForCourseType({
courseId: existing.courseId,
questionTypeIds: (sanitizedData.questions ?? [])
.filter((question) => !('deletedAt' in question && question.deletedAt))
.map((question) => question.questionTypeId)
});
}
if (sanitizedData.slug !== undefined && existing?.courseId) {
sanitizedData.slug = await resolveItemSlug({
courseId: existing.courseId,
title: sanitizedData.title ?? existing.title,
requestedSlug: sanitizedData.slug,
ignoreItemSlug: existing.slug ?? undefined
});
}
const result = await db.transaction(async (tx) => {
const txClient = tx as DbOrTxClient;
@@ -229,6 +400,10 @@ export async function updateExerciseService(exerciseId: string, data: TExerciseU
}
}
if (sanitizedData.sections !== undefined) {
await syncExerciseSections(exerciseId, sanitizedData.sections, txClient);
}
// Process questions - only update what changed
if (sanitizedData.questions && sanitizedData.questions.length > 0) {
// Query current state from DB
@@ -246,31 +421,42 @@ export async function updateExerciseService(exerciseId: string, data: TExerciseU
// Compute diff between current DB state and incoming data
const diff = computeExerciseDiff(currentQuestionsWithOptions, sanitizedData.questions);
// Sync option id sequence so next inserts get ids > max(id) (avoids option_pkey duplicate)
if (
diff.options.creates.length > 0 ||
diff.questions.newQuestions.length > 0 ||
diff.questions.updates.length > 0
) {
const hasNewQuestions = diff.questions.newQuestions.length > 0;
const hasNewOptions = diff.options.creates.length > 0 || hasNewQuestions;
if (hasNewQuestions) {
await syncQuestionIdSequence(txClient);
}
if (hasNewOptions) {
await syncOptionIdSequence(txClient);
}
// Apply all changes in parallel (they operate on different records)
await Promise.all([
// Delete questions marked for deletion (cascade deletes their options)
diff.questions.deletedIds.length > 0 && deleteQuestionsByIds(diff.questions.deletedIds, txClient),
// Update only questions that have changed fields
diff.questions.updates.length > 0 && updateQuestions(diff.questions.updates, txClient),
// Delete options marked for deletion
diff.options.deletedIds.length > 0 && deleteOptionsByIds(diff.options.deletedIds, txClient),
// Update only options that have changed fields
diff.options.updates.length > 0 && updateOptions(diff.options.updates, txClient),
// Create new options for existing questions
diff.options.creates.length > 0 && createOptions(diff.options.creates, txClient),
// Create new questions with their options
diff.questions.newQuestions.length > 0 &&
createNewQuestionsWithOptions(exerciseId, diff.questions.newQuestions, txClient)
]);
// Apply question/option graph changes in dependency order. Question deletes cascade
// options, while new question creation can also create options.
if (diff.questions.deletedIds.length > 0) {
await deleteQuestionsByIds(diff.questions.deletedIds, txClient);
}
if (diff.options.deletedIds.length > 0) {
await deleteOptionsByIds(diff.options.deletedIds, txClient);
}
if (diff.questions.updates.length > 0) {
await updateQuestions(diff.questions.updates, txClient);
}
if (diff.options.updates.length > 0) {
await updateOptions(diff.options.updates, txClient);
}
if (diff.options.creates.length > 0) {
await createOptions(diff.options.creates, txClient);
}
if (hasNewQuestions) {
await createNewQuestionsWithOptions(exerciseId, diff.questions.newQuestions, txClient);
}
}
// Return the updated exercise
@@ -293,6 +479,142 @@ export async function updateExerciseService(exerciseId: string, data: TExerciseU
}
}
export async function updateExerciseSectionMetadataService(
exerciseId: string,
exerciseSectionId: string,
data: { title?: string; description?: string | null }
) {
try {
const sections = await getExerciseSectionsByExerciseId(exerciseId);
const belongs = sections.some((section) => section.id === exerciseSectionId);
if (!belongs) {
throw new AppError(
'That exercise section was not found on this exercise. Call get_exercise_details and use a section id from the sections array.',
ErrorCodes.VALIDATION_ERROR,
404
);
}
const patch: { title?: string; description?: string | null } = {};
if (data.title !== undefined) {
patch.title = sanitizeHtml(data.title);
}
if (data.description !== undefined) {
patch.description = data.description === '' ? null : (sanitizeOptionalHtml(data.description) ?? null);
}
if (Object.keys(patch).length === 0) {
throw new AppError('No fields to update', ErrorCodes.VALIDATION_ERROR, 400);
}
const updated = await updateExerciseSection(exerciseSectionId, patch);
if (!updated) {
throw new AppError('Failed to update exercise section', ErrorCodes.INTERNAL_ERROR, 500);
}
const existing = await getExerciseById(exerciseId);
if (existing?.courseId) {
await touchCourseUpdatedAt(existing.courseId);
}
return {
id: updated.id,
title: updated.title,
description: updated.description ?? null,
exerciseId
};
} catch (error) {
console.error('updateExerciseSectionMetadataService error:', error);
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to update exercise section',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function createExerciseSectionService(
exerciseId: string,
data: {
title: string;
description?: string | null;
order: number;
colorTheme?: 'blue' | 'green' | 'amber' | 'rose' | 'violet' | 'slate';
afterBehavior?:
| { action: 'continue' }
| { action: 'submit' }
| { action: 'go_to_section'; exerciseSectionId: string };
}
) {
try {
const sections = await getExerciseSectionsByExerciseId(exerciseId);
const afterBehavior = data.afterBehavior;
if (afterBehavior?.action === 'go_to_section') {
const targetExists = sections.some((section) => section.id === afterBehavior.exerciseSectionId);
if (!targetExists) {
throw new AppError(
'That exercise section was not found on this exercise. Call get_exercise_details and use a section id from the sections array.',
ErrorCodes.VALIDATION_ERROR,
404
);
}
}
const [created] = await createExerciseSections([
{
exerciseId,
title: data.title,
description: data.description ?? null,
order: data.order,
colorTheme: data.colorTheme ?? 'blue',
afterBehavior: data.afterBehavior ?? { action: 'continue' }
}
]);
if (!created) {
throw new AppError('Failed to create exercise section', ErrorCodes.INTERNAL_ERROR, 500);
}
const existing = await getExerciseById(exerciseId);
if (existing?.courseId) {
await touchCourseUpdatedAt(existing.courseId);
}
return {
id: created.id,
exerciseId: created.exerciseId,
title: created.title,
description: created.description ?? null,
order: created.order,
colorTheme: created.colorTheme,
afterBehavior: created.afterBehavior
};
} catch (error) {
console.error('createExerciseSectionService error:', error);
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to create exercise section',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
/**
* Replaces an exercise's questions and options with the provided payload.
* Existing questions are deleted and recreated from the replacement payload.
@@ -327,6 +649,8 @@ export async function replaceExerciseService(exerciseId: string, data: TExercise
const hasReplacementOptions = replacementQuestions.some((question) => (question.options?.length ?? 0) > 0);
if (replacementQuestions.length > 0) {
await syncQuestionIdSequence(txClient);
if (hasReplacementOptions) {
await syncOptionIdSequence(txClient);
}
@@ -494,6 +818,14 @@ export async function createExerciseFromTemplate(
if (questions && questions.length > 0) {
await db.transaction(async (tx) => {
const txClient = tx as DbOrTxClient;
const hasTemplateOptions = questions.some(
(question) => question.question_type.id !== 3 && (question.options?.length ?? 0) > 0
);
if (hasTemplateOptions) {
await syncOptionIdSequence(txClient);
}
for (const question of questions) {
const { title, question_type, options, order, points } = question;
const questionSettings = sanitizeUnknownStrings(
+9
View File
@@ -25,6 +25,7 @@ type ExerciseAnswer = Pick<
export type QuestionWithRelations = {
id: number | string;
exerciseSectionId?: string | null;
value: string;
name: string;
title: string;
@@ -80,6 +81,8 @@ export function buildExerciseUpdateFields(data: TExerciseUpdate): Partial<TExerc
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;
if (data.sectionDisplayMode !== undefined) exerciseUpdate.sectionDisplayMode = data.sectionDisplayMode;
if (data.slug !== undefined) exerciseUpdate.slug = data.slug;
return exerciseUpdate;
}
@@ -180,6 +183,10 @@ function diffQuestion(
changes.order = incoming.order;
hasChanges = true;
}
if (incoming.exerciseSectionId !== undefined && incoming.exerciseSectionId !== current.exerciseSectionId) {
changes.exerciseSectionId = incoming.exerciseSectionId;
hasChanges = true;
}
if (!areSettingsEqual(current.settings, incoming.settings)) {
changes.settings = normalizeSettings(incoming.settings);
hasChanges = true;
@@ -336,6 +343,7 @@ export async function createNewQuestionsWithOptions(
questionTypeId: q.questionTypeId || 1,
points: q.points || 0,
order: typeof q.order === 'number' ? q.order : undefined,
exerciseSectionId: q.exerciseSectionId ?? null,
settings: normalizeSettings(q.settings)
})),
txClient
@@ -411,6 +419,7 @@ export function transformQuestion(
return {
id: question.id!,
exerciseSectionId: question.exerciseSectionId ?? null,
value: question.title || '', // value field defaults to title
name: question.name || '',
title: question.title,
@@ -0,0 +1,217 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { getStorageConfig } from '@api/config/storage';
import { generateVideoDownloadPresignedUrls, generateVideoUploadPresignedUrl } from '@api/utils/s3';
import { createAsset, getAssetById, updateAsset } from '@cio/db/queries/assets';
import { getExerciseWithRelationsOptimized } from '@cio/db/queries/exercise';
import { getQuestionAnswersBySubmissionId, getSubmissionById } from '@cio/db/queries/submission';
import { getGroupMemberIdByCourseAndProfile, isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group';
import { getCourseOrganizationId as getCourseOrganizationIdFromDb } from '@cio/db/queries/tag';
import { randomUUID } from 'node:crypto';
import type { VideoRecordingAnswerData } from '@cio/question-types';
import {
QUESTION_TYPE_IDS,
VIDEO_RECORDING_MIN_DURATION_SECONDS,
getVideoRecordingMaxDurationSeconds
} from '@cio/question-types';
import type {
TExerciseVideoRecordingUploadComplete,
TExerciseVideoRecordingUploadInit
} from '@cio/utils/validation/exercise';
type RecordingContext = {
organizationId: string;
groupMemberId: string;
maxDurationSeconds: number;
};
function getExtensionFromMimeType(mimeType: string): string {
if (mimeType.includes('mp4')) return 'mp4';
if (mimeType.includes('quicktime')) return 'mov';
return 'webm';
}
async function getCourseOrganizationIdResolved(courseId: string): Promise<string> {
const organizationId = await getCourseOrganizationIdFromDb(courseId);
if (!organizationId) {
throw new AppError('Course organization not found', ErrorCodes.NOT_FOUND, 404);
}
return organizationId;
}
async function getRecordingContext(
courseId: string,
exerciseId: string,
questionId: number,
profileId: string
): Promise<RecordingContext> {
const [organizationId, groupMemberId, exerciseWithRelations] = await Promise.all([
getCourseOrganizationIdResolved(courseId),
getGroupMemberIdByCourseAndProfile(courseId, profileId),
getExerciseWithRelationsOptimized(exerciseId)
]);
if (!groupMemberId) {
throw new AppError('User is not a member of this course', ErrorCodes.UNAUTHORIZED, 403);
}
const question = exerciseWithRelations.questions.find((item) => item.id === questionId);
if (!question) {
throw new AppError('Question not found for this exercise', ErrorCodes.NOT_FOUND, 404);
}
if (question.questionTypeId !== QUESTION_TYPE_IDS.VIDEO_RECORDING) {
throw new AppError('Question is not a video recording question', ErrorCodes.VALIDATION_ERROR, 400);
}
return {
organizationId,
groupMemberId,
maxDurationSeconds: getVideoRecordingMaxDurationSeconds(question.settings as Record<string, unknown> | null)
};
}
export async function initVideoRecordingUpload(
courseId: string,
exerciseId: string,
questionId: number,
profileId: string,
input: TExerciseVideoRecordingUploadInit
) {
const context = await getRecordingContext(courseId, exerciseId, questionId, profileId);
const extension = getExtensionFromMimeType(input.mimeType);
const storageKey = [
'exercise-recordings',
context.organizationId,
courseId,
exerciseId,
String(questionId),
context.groupMemberId,
`${randomUUID()}.${extension}`
].join('/');
const asset = await createAsset({
organizationId: context.organizationId,
kind: 'video',
provider: 'upload',
storageProvider: 's3',
storageKey,
mimeType: input.mimeType,
byteSize: input.size,
title: input.fileName,
status: 'pending',
metadata: {
source: 'exercise_video_recording',
courseId,
exerciseId,
questionId,
groupMemberId: context.groupMemberId,
maxDurationSeconds: context.maxDurationSeconds
},
createdByProfileId: profileId
});
const uploadUrl = await generateVideoUploadPresignedUrl(storageKey, input.mimeType);
const expiresAt = new Date(Date.now() + getStorageConfig().presignUploadExpiresSeconds * 1000).toISOString();
return {
assetId: asset.id,
uploadUrl,
storageKey,
expiresAt
};
}
export async function completeVideoRecordingUpload(
courseId: string,
exerciseId: string,
questionId: number,
profileId: string,
input: TExerciseVideoRecordingUploadComplete
): Promise<VideoRecordingAnswerData> {
const context = await getRecordingContext(courseId, exerciseId, questionId, profileId);
if (input.durationSeconds < VIDEO_RECORDING_MIN_DURATION_SECONDS) {
throw new AppError('Recording is too short', ErrorCodes.VALIDATION_ERROR, 400);
}
if (input.durationSeconds > context.maxDurationSeconds + 2) {
throw new AppError('Recording exceeds the configured duration', ErrorCodes.VALIDATION_ERROR, 400);
}
const asset = await getAssetById(input.assetId, context.organizationId);
if (!asset || asset.storageKey !== input.storageKey) {
throw new AppError('Recording asset not found', ErrorCodes.NOT_FOUND, 404);
}
const uploadedAt = new Date().toISOString();
await updateAsset(input.assetId, context.organizationId, {
status: 'active',
storageKey: input.storageKey,
mimeType: input.mimeType,
byteSize: input.size,
title: input.fileName,
durationSeconds: Math.ceil(input.durationSeconds),
metadata: {
...(asset.metadata && typeof asset.metadata === 'object' && !Array.isArray(asset.metadata) ? asset.metadata : {}),
recordedAt: input.recordedAt,
uploadedAt,
retakeCount: input.retakeCount ?? 0
}
});
return {
type: 'VIDEO_RECORDING',
assetId: input.assetId,
storageKey: input.storageKey,
fileName: input.fileName,
mimeType: input.mimeType,
size: input.size,
durationSeconds: Math.ceil(input.durationSeconds),
recordedAt: input.recordedAt,
uploadedAt,
provider: 'cloudflare',
retakeCount: input.retakeCount ?? 0
};
}
export async function getVideoRecordingPlaybackUrl(
courseId: string,
exerciseId: string,
submissionId: string,
questionId: number,
profileId: string
) {
const [submission, groupMemberId, isReviewer] = await Promise.all([
getSubmissionById(submissionId),
getGroupMemberIdByCourseAndProfile(courseId, profileId),
isCourseTeamMemberOrOrgAdmin(courseId, profileId)
]);
if (!submission || submission.courseId !== courseId || submission.exerciseId !== exerciseId) {
throw new AppError('Submission not found', ErrorCodes.NOT_FOUND, 404);
}
if (!isReviewer && (!groupMemberId || submission.submittedBy !== groupMemberId)) {
throw new AppError('Unauthorized', ErrorCodes.UNAUTHORIZED, 403);
}
const answers = await getQuestionAnswersBySubmissionId(submissionId);
const answer = answers.find((item) => item.questionId === questionId);
const answerData = answer?.answerData;
if (!answerData || answerData.type !== 'VIDEO_RECORDING') {
throw new AppError('Video recording answer not found', ErrorCodes.NOT_FOUND, 404);
}
const urls = await generateVideoDownloadPresignedUrls([answerData.storageKey]);
const playbackUrl = urls[answerData.storageKey];
if (!playbackUrl) {
throw new AppError('Video recording is unavailable', ErrorCodes.INTERNAL_ERROR, 500);
}
return {
assetId: answerData.assetId,
playbackUrl,
expiresAt: new Date(Date.now() + getStorageConfig().presignDownloadExpiresSeconds * 1000).toISOString()
};
}
+29 -2
View File
@@ -20,6 +20,7 @@ import {
} from '@cio/db/queries/lesson';
import { touchCourseUpdatedAt } from '@cio/db/queries/course';
import { enrichLessonWithPresignedUrls } from '@api/utils/lesson-media';
import { resolveItemSlug } from '@api/services/course/slug';
/**
* Creates a new lesson
@@ -29,9 +30,16 @@ import { enrichLessonWithPresignedUrls } from '@api/utils/lesson-media';
*/
export async function createLesson(courseId: string, data: TLessonCreate): Promise<TLesson> {
try {
const slug = await resolveItemSlug({
courseId,
title: data.title,
requestedSlug: data.slug
});
const lessonData = {
...data,
courseId
courseId,
slug
};
const [lesson] = await createLessons([lessonData]);
@@ -89,7 +97,26 @@ export async function getLesson(lessonId: string): Promise<LessonById> {
*/
export async function updateLessonService(lessonId: string, data: TLessonUpdate): Promise<TLesson> {
try {
const updated = await updateLesson(lessonId, data);
const payload: TLessonUpdate = { ...data };
if (payload.slug !== undefined) {
const existing = await getLessonById(lessonId);
if (!existing) {
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
}
if (!existing.courseId) {
throw new AppError('Lesson has no course', ErrorCodes.LESSON_NOT_FOUND, 404);
}
payload.slug = await resolveItemSlug({
courseId: existing.courseId,
title: payload.title ?? existing.title,
requestedSlug: payload.slug,
ignoreItemSlug: existing.slug ?? undefined
});
}
const updated = await updateLesson(lessonId, payload);
if (!updated) {
throw new AppError('Failed to update lesson', ErrorCodes.INTERNAL_ERROR, 500);
}
+76 -167
View File
@@ -1,24 +1,29 @@
import * as schema from '@cio/db/schema';
import { AppError, ErrorCodes } from '@api/utils/errors';
import { and, eq, isNull } from 'drizzle-orm';
import {
checkEmailsExistInOrg,
claimPendingOrganizationInvite,
createOrganizationInvite,
createOrganizationInviteAudit,
createOrganizationMember,
createOrganizationMembers,
getActivePendingOrgInviteForEmail,
getOrganizationById,
getOrganizationInviteByTokenHash,
revokeActiveOrganizationInvitesByEmails
revokeActiveOrganizationInvitesByEmails,
selectOrganizationInviteWithOrgByInviteId,
selectOrganizationInviteWithOrgByTokenHash,
selectOrganizationMemberByOrgAndNormalizedEmail,
selectOrganizationMemberByOrgAndProfile,
updateOrganizationMemberById
} from '@cio/db/queries/organization';
import { getCourseGroupIds } from '@cio/db/queries/course';
import { enrollUsersInCourseGroups } from '@cio/db/queries/group';
import { addProgramMember, getExistingProgramMembers } from '@cio/db/queries/program';
import { ROLE } from '@cio/utils/constants';
import type { TNewOrganizationInviteAudit } from '@db/types';
import crypto from 'node:crypto';
import { db } from '@cio/db/drizzle';
import { db, type DbOrTxClient } from '@cio/db/drizzle';
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { parseCourseIdsFromInviteMetadata, parseProgramIdsFromInviteMetadata } from '@api/utils/org';
import { markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile';
@@ -90,10 +95,59 @@ function getExpiryLabel(expiresAtIso: string): string {
});
}
async function syncOrgMemberForOrgInvite(
tx: DbOrTxClient,
params: { organizationId: string; roleId: number; normalizedEmail: string; userId: string }
): Promise<void> {
const orgMemberByEmail = await selectOrganizationMemberByOrgAndNormalizedEmail(
tx,
params.organizationId,
params.normalizedEmail
);
if (orgMemberByEmail) {
if (orgMemberByEmail.profileId && orgMemberByEmail.profileId !== params.userId) {
throw new AppError('This invite is linked to another account', ErrorCodes.UNAUTHORIZED, 403);
}
await updateOrganizationMemberById(tx, orgMemberByEmail.id, {
profileId: params.userId,
roleId: params.roleId,
email: params.normalizedEmail,
verified: true
});
return;
}
const orgMemberByProfile = await selectOrganizationMemberByOrgAndProfile(tx, params.organizationId, params.userId);
if (orgMemberByProfile) {
await updateOrganizationMemberById(tx, orgMemberByProfile.id, {
roleId: params.roleId,
email: params.normalizedEmail,
verified: true
});
return;
}
await createOrganizationMember(
{
organizationId: params.organizationId,
roleId: params.roleId,
profileId: params.userId,
email: params.normalizedEmail,
verified: true
},
tx
);
}
async function recordOrganizationInviteAudit(
inviteId: string,
organizationId: string,
eventType: (typeof schema.organizationInviteEventType.enumValues)[number],
eventType: TNewOrganizationInviteAudit['eventType'],
context: {
actorProfileId?: string | null;
targetEmail?: string | null;
@@ -281,19 +335,7 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
const tokenHash = hashToken(token);
const result = await db.transaction(async (tx) => {
const [row] = await tx
.select({
invite: schema.organizationInvite,
organization: {
id: schema.organization.id,
name: schema.organization.name,
siteName: schema.organization.siteName
}
})
.from(schema.organizationInvite)
.innerJoin(schema.organization, eq(schema.organizationInvite.organizationId, schema.organization.id))
.where(eq(schema.organizationInvite.tokenHash, tokenHash))
.limit(1);
const row = await selectOrganizationInviteWithOrgByTokenHash(tx, tokenHash);
if (!row) {
throw new AppError('Invalid invite link', ErrorCodes.NOT_FOUND, 404);
@@ -321,82 +363,16 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
};
}
const [orgMemberByEmail] = await tx
.select()
.from(schema.organizationmember)
.where(
and(
eq(schema.organizationmember.organizationId, row.invite.organizationId),
eq(schema.organizationmember.email, normalizedEmail)
)
)
.limit(1);
if (orgMemberByEmail) {
if (orgMemberByEmail.profileId && orgMemberByEmail.profileId !== user.id) {
throw new AppError('This invite is linked to another account', ErrorCodes.UNAUTHORIZED, 403);
}
await tx
.update(schema.organizationmember)
.set({
profileId: user.id,
roleId: row.invite.roleId,
email: normalizedEmail,
verified: true
})
.where(eq(schema.organizationmember.id, orgMemberByEmail.id));
} else {
const [orgMemberByProfile] = await tx
.select()
.from(schema.organizationmember)
.where(
and(
eq(schema.organizationmember.organizationId, row.invite.organizationId),
eq(schema.organizationmember.profileId, user.id)
)
)
.limit(1);
if (orgMemberByProfile) {
await tx
.update(schema.organizationmember)
.set({
roleId: row.invite.roleId,
email: normalizedEmail,
verified: true
})
.where(eq(schema.organizationmember.id, orgMemberByProfile.id));
} else {
await tx.insert(schema.organizationmember).values({
organizationId: row.invite.organizationId,
roleId: row.invite.roleId,
profileId: user.id,
email: normalizedEmail,
verified: true
});
}
}
await syncOrgMemberForOrgInvite(tx, {
organizationId: row.invite.organizationId,
roleId: row.invite.roleId,
normalizedEmail,
userId: user.id
});
await markUserAndProfileEmailVerified(user.id, tx);
const [acceptedInvite] = await tx
.update(schema.organizationInvite)
.set({
acceptedAt: new Date().toISOString(),
acceptedByProfileId: user.id,
updatedAt: new Date().toISOString()
})
.where(
and(
eq(schema.organizationInvite.id, row.invite.id),
eq(schema.organizationInvite.isRevoked, false),
isNull(schema.organizationInvite.acceptedAt)
)
)
.returning({
id: schema.organizationInvite.id
});
const acceptedInvite = await claimPendingOrganizationInvite(tx, row.invite.id, user.id);
if (!acceptedInvite) {
throw new AppError('Invite is no longer available', ErrorCodes.VALIDATION_ERROR, 409);
@@ -523,19 +499,7 @@ export async function acceptOrganizationInviteById(
const normalizedEmail = user.email.toLowerCase().trim();
const result = await db.transaction(async (tx) => {
const [row] = await tx
.select({
invite: schema.organizationInvite,
organization: {
id: schema.organization.id,
name: schema.organization.name,
siteName: schema.organization.siteName
}
})
.from(schema.organizationInvite)
.innerJoin(schema.organization, eq(schema.organizationInvite.organizationId, schema.organization.id))
.where(eq(schema.organizationInvite.id, inviteId))
.limit(1);
const row = await selectOrganizationInviteWithOrgByInviteId(tx, inviteId);
if (!row) {
throw new AppError('Invalid invite', ErrorCodes.NOT_FOUND, 404);
@@ -560,71 +524,16 @@ export async function acceptOrganizationInviteById(
return { organization: row.organization, invite: row.invite, roleId: row.invite.roleId, alreadyAccepted: true };
}
const [orgMemberByEmail] = await tx
.select()
.from(schema.organizationmember)
.where(
and(
eq(schema.organizationmember.organizationId, row.invite.organizationId),
eq(schema.organizationmember.email, normalizedEmail)
)
)
.limit(1);
if (orgMemberByEmail) {
if (orgMemberByEmail.profileId && orgMemberByEmail.profileId !== user.id) {
throw new AppError('This invite is linked to another account', ErrorCodes.UNAUTHORIZED, 403);
}
await tx
.update(schema.organizationmember)
.set({ profileId: user.id, roleId: row.invite.roleId, email: normalizedEmail, verified: true })
.where(eq(schema.organizationmember.id, orgMemberByEmail.id));
} else {
const [orgMemberByProfile] = await tx
.select()
.from(schema.organizationmember)
.where(
and(
eq(schema.organizationmember.organizationId, row.invite.organizationId),
eq(schema.organizationmember.profileId, user.id)
)
)
.limit(1);
if (orgMemberByProfile) {
await tx
.update(schema.organizationmember)
.set({ roleId: row.invite.roleId, email: normalizedEmail, verified: true })
.where(eq(schema.organizationmember.id, orgMemberByProfile.id));
} else {
await tx.insert(schema.organizationmember).values({
organizationId: row.invite.organizationId,
roleId: row.invite.roleId,
profileId: user.id,
email: normalizedEmail,
verified: true
});
}
}
await syncOrgMemberForOrgInvite(tx, {
organizationId: row.invite.organizationId,
roleId: row.invite.roleId,
normalizedEmail,
userId: user.id
});
await markUserAndProfileEmailVerified(user.id, tx);
const [acceptedInvite] = await tx
.update(schema.organizationInvite)
.set({
acceptedAt: new Date().toISOString(),
acceptedByProfileId: user.id,
updatedAt: new Date().toISOString()
})
.where(
and(
eq(schema.organizationInvite.id, row.invite.id),
eq(schema.organizationInvite.isRevoked, false),
isNull(schema.organizationInvite.acceptedAt)
)
)
.returning({ id: schema.organizationInvite.id });
const acceptedInvite = await claimPendingOrganizationInvite(tx, row.invite.id, user.id);
if (!acceptedInvite) {
throw new AppError('Invite is no longer available', ErrorCodes.VALIDATION_ERROR, 409);
+30 -28
View File
@@ -12,6 +12,7 @@ import {
} from '@cio/utils/validation/program';
import {
addCourseToProgram,
addProgramMember,
createProgramWithCreatorMembership,
createProgramNewsfeed as createProgramNewsfeedQuery,
createProgramNewsfeedComment as createProgramNewsfeedCommentQuery,
@@ -39,10 +40,11 @@ import {
updateProgramNewsfeedReaction
} from '@cio/db/queries/program';
import { getCourseGroupIds } from '@cio/db/queries/course';
import { insertGroupMembersOnConflictDoNothing } from '@cio/db/queries/group';
import { getProfileByEmail } from '@cio/db/queries/auth';
import { insertOrganizationMembersOnConflictDoNothing } from '@cio/db/queries/organization';
import { ROLE } from '@cio/utils/constants';
import { db } from '@cio/db/drizzle';
import * as schema from '@db/schema';
type ProgramMemberEnrollment = {
profileId: string;
@@ -80,8 +82,8 @@ async function enrollProgramStudentsInGroups(
);
await db.transaction(async (tx) => {
await tx.insert(schema.organizationmember).values(organizationMemberRows).onConflictDoNothing();
await tx.insert(schema.groupmember).values(groupMemberRows).onConflictDoNothing();
await insertOrganizationMembersOnConflictDoNothing(organizationMemberRows, tx);
await insertGroupMembersOnConflictDoNothing(groupMemberRows, tx);
});
return groupMemberRows.length;
@@ -230,41 +232,41 @@ export async function addProgramMembers(programId: string, data: TAddProgramMemb
}
return db.transaction(async (tx) => {
const [member] = await tx
.insert(schema.programMember)
.values({
const member = await addProgramMember(
{
programId,
profileId,
roleId,
email: normalizedEmail ?? undefined
})
.returning();
},
tx
);
if (member && member.roleId === ROLE.STUDENT && member.profileId && courseGroupIds.length > 0) {
const validCourseGroupIds = courseGroupIds.filter((groupId): groupId is string => Boolean(groupId));
await tx
.insert(schema.organizationmember)
.values({
organizationId: program.organizationId,
roleId: ROLE.STUDENT,
profileId: member.profileId,
email: normalizedEmail ?? undefined,
verified: true
})
.onConflictDoNothing();
await tx
.insert(schema.groupmember)
.values(
validCourseGroupIds.map((groupId) => ({
groupId,
await insertOrganizationMembersOnConflictDoNothing(
[
{
organizationId: program.organizationId,
roleId: ROLE.STUDENT,
profileId: member.profileId,
email: normalizedEmail ?? undefined
}))
)
.onConflictDoNothing();
email: normalizedEmail ?? undefined,
verified: true
}
],
tx
);
await insertGroupMembersOnConflictDoNothing(
validCourseGroupIds.map((groupId) => ({
groupId,
roleId: ROLE.STUDENT,
profileId: member.profileId,
email: normalizedEmail ?? undefined
})),
tx
);
}
return member;
+78 -13
View File
@@ -1,5 +1,10 @@
import type { AnswerData, FileUploadAnswerData } from '@cio/question-types';
import { scoreSubmissionAnswers, type ScoreSubmissionResult, validateTextareaAnswer } from '@cio/question-types';
import type { AnswerData, FileUploadAnswerData, VideoRecordingAnswerData } from '@cio/question-types';
import {
getVideoRecordingMaxDurationSeconds,
scoreSubmissionAnswers,
type ScoreSubmissionResult,
validateTextareaAnswer
} from '@cio/question-types';
import { AppError, ErrorCodes } from '@api/utils/errors';
import type { TNewQuestionAnswer, TNewSubmission, TSubmission } from '@cio/db/types';
import type {
@@ -21,6 +26,7 @@ import {
updateSubmission,
updateSubmissionGrades
} from '@cio/db/queries/submission';
import { createAssetUsage, getAssetById } from '@cio/db/queries/assets';
import {
fromApiPayload,
getQuestionTypeById,
@@ -35,7 +41,7 @@ import { getGroupMemberIdByCourseAndProfile, isCourseTeamMemberOrOrgAdmin } from
import { QUESTION_TYPE_ID_TO_KEY } from '@cio/question-types';
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { generateDocumentDownloadPresignedUrls } from '@api/utils/s3';
import { generateDocumentDownloadPresignedUrls, generateVideoDownloadPresignedUrls } from '@api/utils/s3';
import { syncComplianceProgressFromSubmission } from '@api/services/course/compliance';
type SubmissionGradingState = 'queued' | 'processing' | 'awaiting_manual' | 'completed' | 'failed';
@@ -139,6 +145,10 @@ function isFileUpload(data: unknown): data is FileUploadAnswerData {
return !!data && typeof data === 'object' && (data as { type: string }).type === 'FILE_UPLOAD';
}
function isVideoRecording(data: unknown): data is VideoRecordingAnswerData {
return !!data && typeof data === 'object' && (data as { type: string }).type === 'VIDEO_RECORDING';
}
/**
* Enriches FILE_UPLOAD answers from DB rows (question_answer[]) with presigned download URLs.
* Used by getSubmission and listSubmissionsByExercise.
@@ -148,15 +158,27 @@ async function enrichFileUploadAnswersArray<T extends { answerData?: unknown }>(
.map((a) => a.answerData)
.filter(isFileUpload)
.map((d) => d.fileKey);
const videoKeys = answers
.map((a) => a.answerData)
.filter(isVideoRecording)
.map((d) => d.storageKey);
if (fileKeys.length === 0) return answers;
if (fileKeys.length === 0 && videoKeys.length === 0) return answers;
try {
const urls = await generateDocumentDownloadPresignedUrls(fileKeys);
const [documentUrls, videoUrls] = await Promise.all([
generateDocumentDownloadPresignedUrls(fileKeys),
generateVideoDownloadPresignedUrls(videoKeys)
]);
return answers.map((answer) => {
if (!isFileUpload(answer.answerData)) return answer;
const url = urls[answer.answerData.fileKey];
return url ? { ...answer, answerData: { ...answer.answerData, fileUrl: url } } : answer;
if (isFileUpload(answer.answerData)) {
const url = documentUrls[answer.answerData.fileKey];
return url ? { ...answer, answerData: { ...answer.answerData, fileUrl: url } } : answer;
}
if (!isVideoRecording(answer.answerData)) return answer;
const url = videoUrls[answer.answerData.storageKey];
return url ? { ...answer, answerData: { ...answer.answerData, playbackUrl: url } } : answer;
});
} catch (error) {
console.error('enrichFileUploadAnswersArray error:', error);
@@ -172,16 +194,27 @@ async function enrichFileUploadAnswersObject(answers: Record<string, AnswerData>
const fileKeys = Object.values(answers)
.filter(isFileUpload)
.map((d) => d.fileKey);
const videoKeys = Object.values(answers)
.filter(isVideoRecording)
.map((d) => d.storageKey);
if (fileKeys.length === 0) return answers;
if (fileKeys.length === 0 && videoKeys.length === 0) return answers;
try {
const urls = await generateDocumentDownloadPresignedUrls(fileKeys);
const [documentUrls, videoUrls] = await Promise.all([
generateDocumentDownloadPresignedUrls(fileKeys),
generateVideoDownloadPresignedUrls(videoKeys)
]);
return Object.fromEntries(
Object.entries(answers).map(([key, data]) => {
if (!isFileUpload(data)) return [key, data];
const url = urls[data.fileKey];
return url ? [key, { ...data, fileUrl: url }] : [key, data];
if (isFileUpload(data)) {
const url = documentUrls[data.fileKey];
return url ? [key, { ...data, fileUrl: url }] : [key, data];
}
if (!isVideoRecording(data)) return [key, data];
const url = videoUrls[data.storageKey];
return url ? [key, { ...data, playbackUrl: url }] : [key, data];
})
);
} catch (error) {
@@ -190,6 +223,30 @@ async function enrichFileUploadAnswersObject(answers: Record<string, AnswerData>
}
}
async function createVideoRecordingAssetUsages(answers: Array<TNewQuestionAnswer & { id?: number }>): Promise<void> {
const videoAnswers = answers.filter((answer) => isVideoRecording(answer.answerData));
if (videoAnswers.length === 0) return;
await Promise.all(
videoAnswers.map(async (answer) => {
if (!answer.id || !isVideoRecording(answer.answerData)) return;
const asset = await getAssetById(answer.answerData.assetId);
if (!asset) return;
await createAssetUsage({
organizationId: asset.organizationId,
assetId: answer.answerData.assetId,
targetType: 'submission_answer',
targetId: String(answer.id),
slotType: 'video_recording_answer',
slotKey: String(answer.questionId),
createdByProfileId: null
});
})
);
}
/**
* Gets a submission by ID with question answers
* @param submissionId Submission ID
@@ -563,6 +620,13 @@ export async function createSubmissionService(
}
}
if (answerData.type === 'VIDEO_RECORDING') {
const maxDurationSeconds = getVideoRecordingMaxDurationSeconds(question.settings);
if (answerData.durationSeconds > maxDurationSeconds + 2) {
throw new AppError('Recording exceeds the configured duration', ErrorCodes.VALIDATION_ERROR, 400);
}
}
answerByQuestionId.set(ans.questionId, answerData);
answerRows.push({
@@ -594,6 +658,7 @@ export async function createSubmissionService(
}
const insertedAnswers = answerRows.length > 0 ? await insertQuestionAnswersBatch(answerRows) : [];
await createVideoRecordingAssetUsages(insertedAnswers);
if (shouldAutoGrade) {
const dbQuestions = exerciseWithRelations.questions