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,23 @@
import { z } from 'zod';
export const ZExerciseSectionAfterBehavior = z.discriminatedUnion('action', [
z.object({ action: z.literal('continue') }),
z.object({ action: z.literal('go_to_section'), exerciseSectionId: z.string().uuid() }),
z.object({ action: z.literal('submit') })
]);
export const ZExerciseSection = z.object({
id: z.string().uuid().optional(),
title: z.string().min(1, 'Section title is required'),
description: z.string().optional().nullable(),
order: z.number().int().min(0),
colorTheme: z.enum(['blue', 'green', 'amber', 'rose', 'violet', 'slate']).default('blue'),
afterBehavior: ZExerciseSectionAfterBehavior.default({ action: 'continue' }),
questionIds: z.array(z.union([z.string(), z.number().int()])).optional()
});
export const ZExerciseSectionCreate = ZExerciseSection.omit({ id: true });
export const ZExerciseSectionUpdate = ZExerciseSection.partial().required({ id: true });
export type TExerciseSection = z.infer<typeof ZExerciseSection>;
export type TExerciseSectionAfterBehavior = z.infer<typeof ZExerciseSectionAfterBehavior>;
@@ -1,8 +1,14 @@
import * as z from 'zod';
import { getStarRatingMaxFromSettings, isValidStarRatingValue } from '@cio/question-types';
import {
VIDEO_RECORDING_PLATFORM_MAX_DURATION_SECONDS,
getStarRatingMaxFromSettings,
isValidStarRatingValue
} from '@cio/question-types';
import { QUESTION_TYPE } from '../constants';
import { ZSlug } from '../shared/slug';
import { ZExerciseSection } from './exercise-section';
const EXERCISE_QUESTION_TYPE_ID_LITERALS = [
z.literal(QUESTION_TYPE.RADIO),
@@ -18,7 +24,8 @@ const EXERCISE_QUESTION_TYPE_ID_LITERALS = [
z.literal(QUESTION_TYPE.HOTSPOT),
z.literal(QUESTION_TYPE.LINK),
z.literal(QUESTION_TYPE.WORD_BANK),
z.literal(QUESTION_TYPE.STAR)
z.literal(QUESTION_TYPE.STAR),
z.literal(QUESTION_TYPE.VIDEO_RECORDING)
] as const;
export const ZExerciseQuestionTypeId = z.union(EXERCISE_QUESTION_TYPE_ID_LITERALS);
@@ -110,6 +117,22 @@ const QUESTION_VALIDATION_RULES: Record<number, Array<(question: QuestionRuleInp
return 'Set a correct star value from 1 up to the maximum number of stars';
}
return null;
}
],
[QUESTION_TYPE.VIDEO_RECORDING]: [
(question) => {
const settings = question.settings ?? {};
const rawDuration = settings.maxDurationSeconds;
const maxDurationSeconds =
typeof rawDuration === 'number' ? rawDuration : rawDuration != null ? Number(rawDuration) : NaN;
if (!Number.isFinite(maxDurationSeconds) || maxDurationSeconds <= 0) {
return 'Set a recording duration greater than 0 seconds';
}
if (maxDurationSeconds > VIDEO_RECORDING_PLATFORM_MAX_DURATION_SECONDS) {
return `Recording duration cannot exceed ${VIDEO_RECORDING_PLATFORM_MAX_DURATION_SECONDS} seconds`;
}
return null;
}
]
@@ -118,6 +141,7 @@ const QUESTION_VALIDATION_RULES: Record<number, Array<(question: QuestionRuleInp
// Base schema for exercise update question (without refinement)
const ZExerciseUpdateQuestionBase = z.object({
id: z.number().int().optional(),
exerciseSectionId: z.string().uuid().nullable().optional(),
question: z.string().min(1),
questionTypeId: ZExerciseQuestionTypeId.optional(),
points: z.number().int().min(0).optional(),
@@ -183,6 +207,7 @@ export const ZExerciseCreate = z.object({
order: z.number().int().optional(),
courseId: z.string().min(1),
dueBy: z.string().optional(),
slug: ZSlug.optional(),
questions: z.array(ZExerciseCreateQuestion).optional()
});
export type TExerciseCreate = z.infer<typeof ZExerciseCreate>;
@@ -196,7 +221,10 @@ export const ZExerciseUpdate = z.object({
isUnlocked: z.boolean().optional(),
dueBy: z.string().optional(), // Changed from iso.datetime() to string to match frontend format
allowMultipleAttempts: z.boolean().optional(),
questions: z.array(ZExerciseUpdateQuestion).optional()
slug: ZSlug.optional(),
questions: z.array(ZExerciseUpdateQuestion).optional(),
sections: z.array(ZExerciseSection).optional(),
sectionDisplayMode: z.enum(['one_question', 'all_questions']).optional()
});
export type TExerciseUpdate = z.infer<typeof ZExerciseUpdate>;
@@ -1 +1,3 @@
export * from './exercise';
export * from './exercise-section';
export * from './video-recording';
@@ -0,0 +1,38 @@
import * as z from 'zod';
import { VIDEO_RECORDING_PLATFORM_MAX_DURATION_SECONDS } from '@cio/question-types';
export const VIDEO_RECORDING_MIME_TYPES = ['video/webm', 'video/mp4', 'video/quicktime'] as const;
export const ZExerciseVideoRecordingParam = z.object({
exerciseId: z.string().min(1),
questionId: z.string().regex(/^\d+$/).transform(Number)
});
export const ZExerciseVideoRecordingPlaybackParam = ZExerciseVideoRecordingParam.extend({
submissionId: z.string().uuid()
});
export const ZExerciseVideoRecordingUploadInit = z.object({
fileName: z.string().min(1),
mimeType: z.enum(VIDEO_RECORDING_MIME_TYPES),
size: z.number().int().positive()
});
export const ZExerciseVideoRecordingUploadComplete = z.object({
assetId: z.string().uuid(),
storageKey: z.string().min(1),
fileName: z.string().min(1),
mimeType: z.enum(VIDEO_RECORDING_MIME_TYPES),
size: z.number().int().positive(),
durationSeconds: z
.number()
.positive()
.max(VIDEO_RECORDING_PLATFORM_MAX_DURATION_SECONDS + 2),
recordedAt: z.string().datetime(),
retakeCount: z.number().int().min(0).optional()
});
export type TExerciseVideoRecordingParam = z.infer<typeof ZExerciseVideoRecordingParam>;
export type TExerciseVideoRecordingPlaybackParam = z.infer<typeof ZExerciseVideoRecordingPlaybackParam>;
export type TExerciseVideoRecordingUploadInit = z.infer<typeof ZExerciseVideoRecordingUploadInit>;
export type TExerciseVideoRecordingUploadComplete = z.infer<typeof ZExerciseVideoRecordingUploadComplete>;