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
+33 -10
View File
@@ -7,36 +7,59 @@
* even though it isn't currently exposed to users.
*/
export const AGENT_MODEL_IDS = ['gemini-2.5-flash', 'gpt-4o', 'claude-sonnet-4-5'] as const;
export const AGENT_MODEL_IDS = ['gemini-2.5-flash', 'gpt-5.4-mini', 'claude-sonnet-3-5', 'kimi-k2.6'] as const;
export type AgentModelId = (typeof AGENT_MODEL_IDS)[number];
export type AgentModelProvider = 'google' | 'openai' | 'anthropic';
export type AgentModelProvider = 'google' | 'openai' | 'anthropic' | 'moonshot';
export type AgentModelCostTier = 'low' | 'high';
export interface AgentModelDescriptor {
provider: AgentModelProvider;
label: string;
/** The exact model id passed to the provider SDK. */
backendModelId: string;
/** Whether this model is available on the free plan. */
isFree: boolean;
/** Cost tier shown in the model picker — 'low' ($) or 'high' ($$$). */
costTier: AgentModelCostTier;
}
export const AGENT_MODELS: Record<AgentModelId, AgentModelDescriptor> = {
'gemini-2.5-flash': {
provider: 'google',
label: 'Gemini 2.5 Flash',
backendModelId: 'gemini-2.5-flash'
backendModelId: 'gemini-2.5-flash',
isFree: true,
costTier: 'low'
},
'gpt-4o': {
'gpt-5.4-mini': {
provider: 'openai',
label: 'GPT-4o',
backendModelId: 'gpt-4o'
label: 'GPT-5.4 Mini',
backendModelId: 'gpt-5.4-mini',
isFree: false,
costTier: 'low'
},
'claude-sonnet-4-5': {
'claude-sonnet-3-5': {
provider: 'anthropic',
label: 'Claude Sonnet 4.5',
backendModelId: 'claude-sonnet-4-5-20250929'
label: 'Claude Sonnet 4.6',
backendModelId: 'claude-sonnet-4-6',
isFree: false,
costTier: 'high'
},
'kimi-k2.6': {
provider: 'moonshot',
label: 'Kimi K2.6',
backendModelId: 'kimi-k2.6',
isFree: true,
costTier: 'low'
}
};
export const UI_PICKER_MODEL_IDS = ['gemini-2.5-flash', 'gpt-4o'] as const satisfies readonly AgentModelId[];
export const UI_PICKER_MODEL_IDS = [
'gemini-2.5-flash',
'kimi-k2.6',
'gpt-5.4-mini',
'claude-sonnet-3-5'
] as const satisfies readonly AgentModelId[];
export const DEFAULT_PICKER_MODEL_ID: AgentModelId = 'gemini-2.5-flash';
@@ -94,6 +94,9 @@ export const ErrorCodes = {
COURSE_IMPORT_FETCH_FAILED: 'COURSE_IMPORT_FETCH_FAILED',
COURSE_IMPORT_UPDATE_FAILED: 'COURSE_IMPORT_UPDATE_FAILED',
COURSE_IMPORT_PUBLISH_FAILED: 'COURSE_IMPORT_PUBLISH_FAILED',
SLUG_IN_USE: 'SLUG_IN_USE',
QUESTION_TYPE_NOT_ALLOWED_IN_PUBLIC_COURSE: 'QUESTION_TYPE_NOT_ALLOWED_IN_PUBLIC_COURSE',
PUBLIC_COURSE_CONVERSION_BLOCKED: 'PUBLIC_COURSE_CONVERSION_BLOCKED',
// Lesson errors
LESSON_NOT_FOUND: 'LESSON_NOT_FOUND',
+3 -1
View File
@@ -14,7 +14,7 @@ export default {
'Unlimited Programs',
'20 Students',
'Advanced Course Builder (no video upload)',
'50K AI credits / month',
'500K AI credits / month',
'MCP access with 20 automation credits / month',
'ClassroomIO branding'
],
@@ -40,6 +40,7 @@ export default {
'Unlimited Collaborators',
'10K Students',
'Course Builder with Video Upload & Certificate',
'3M AI credits / month',
'MCP access with 500 automation credits / month',
'Custom Branding',
'Custom Domain',
@@ -68,6 +69,7 @@ export default {
FEATURES: [
'Everything in Early Adopters plus:',
'Unlimited students',
'15M AI credits / month',
'Unlimited API requests',
'SSO',
'24/7 Support',
+1
View File
@@ -1,6 +1,7 @@
export * from './constants';
export * from './automation';
export * from './utils';
export * from './token-packs';
import PLANS from './data';
+13
View File
@@ -0,0 +1,13 @@
export const TOKEN_PACK = {
TOKENS_PER_UNIT: 5_000_000,
PRICE_USD_CENTS: 500,
CURRENCY: 'USD'
} as const;
export type TokenPackMetadata = {
kind: 'token_pack';
orgId: string;
orgSlug: string;
triggeredBy: string;
tokensPerUnit: number;
};
+28 -5
View File
@@ -1,10 +1,12 @@
import { z } from 'zod';
import { AGENT_MODEL_IDS } from '../../agent-models';
const ZAgentCourseId = z.string().min(1);
// ─── POST /agent/chat ────────────────────────────────────────────────────────
export const ZAgentChatBody = z.object({
courseId: z.string().uuid(),
courseId: ZAgentCourseId,
messages: z.array(z.any()), // UIMessage[] from Vercel AI SDK — validated by the SDK itself
model: z.enum(AGENT_MODEL_IDS).optional(),
context: z
@@ -21,7 +23,7 @@ export type TAgentChatBody = z.infer<typeof ZAgentChatBody>;
// ─── POST /agent/upload ──────────────────────────────────────────────────────
export const ZAgentUploadQuery = z.object({
courseId: z.string().uuid(),
courseId: ZAgentCourseId,
conversationId: z.string().uuid()
});
@@ -30,7 +32,7 @@ export type TAgentUploadQuery = z.infer<typeof ZAgentUploadQuery>;
// ─── GET /agent/status ───────────────────────────────────────────────────────
export const ZAgentStatusQuery = z.object({
courseId: z.string().uuid()
courseId: ZAgentCourseId
});
export type TAgentStatusQuery = z.infer<typeof ZAgentStatusQuery>;
@@ -43,6 +45,19 @@ export const ZAgentCreditsBody = z.object({
export type TAgentCreditsBody = z.infer<typeof ZAgentCreditsBody>;
export const ZAgentCreditPurchase = z.object({
orgId: z.string().uuid(),
triggeredBy: z.string().uuid().optional(),
providerOrderId: z.string().min(1),
tokens: z.number().int().positive(),
quantity: z.number().int().positive(),
unitPriceCents: z.number().int().nonnegative(),
currency: z.string().default('USD'),
payload: z.record(z.string(), z.unknown()).optional()
});
export type TAgentCreditPurchase = z.infer<typeof ZAgentCreditPurchase>;
// ─── Supported MIME types ────────────────────────────────────────────────────
export const SUPPORTED_UPLOAD_MIME_TYPES = [
@@ -56,7 +71,7 @@ export const MAX_AGENT_DOCUMENT_SIZE = 5 * 1024 * 1024; // 5MB
// ─── GET /agent/history (list conversations) ────────────────────────────────
export const ZAgentHistoryQuery = z.object({
courseId: z.string().uuid()
courseId: ZAgentCourseId
});
export type TAgentHistoryQuery = z.infer<typeof ZAgentHistoryQuery>;
@@ -72,7 +87,7 @@ export type TAgentConversationParam = z.infer<typeof ZAgentConversationParam>;
// ─── POST /agent/history (create conversation) ──────────────────────────────
export const ZAgentConversationCreateBody = z.object({
courseId: z.string().uuid(),
courseId: ZAgentCourseId,
title: z.string().optional()
});
@@ -107,3 +122,11 @@ export const ZAgentGenerateTitleBody = z.object({
export type TAgentGenerateTitleParam = z.infer<typeof ZAgentGenerateTitleParam>;
export type TAgentGenerateTitleBody = z.infer<typeof ZAgentGenerateTitleBody>;
// ─── POST /agent/generate-course-title ───────────────────────────────────────
export const ZAgentGenerateCourseTitleBody = z.object({
prompt: z.string().min(1).max(2000)
});
export type TAgentGenerateCourseTitleBody = z.infer<typeof ZAgentGenerateCourseTitleBody>;
@@ -0,0 +1,20 @@
import * as z from 'zod';
/**
* Creator-configured callout shown on public course lesson pages.
* Rendered inline at the bottom of every item body, and as the full replacement
* for locked items.
*/
export const ZCourseCallout = z.object({
title: z.string().min(1).max(120),
description: z.string().min(1).max(500),
buttonLabel: z.string().min(1).max(40),
buttonUrl: z.string().url()
});
export type TCourseCallout = z.infer<typeof ZCourseCallout>;
/**
* Accepts the callout object, or `null` to clear it.
*/
export const ZCourseCalloutInput = ZCourseCallout.nullable();
export type TCourseCalloutInput = z.infer<typeof ZCourseCalloutInput>;
@@ -1,6 +1,7 @@
import * as z from 'zod';
import { ALLOWED_CONTENT_TYPES, ALLOWED_DOCUMENT_TYPES } from '../constants';
import { ZCourseCalloutInput } from './callout';
export const ZCourseClone = z.object({
title: z.string().min(1),
@@ -227,7 +228,7 @@ export type TComplianceSettings = z.infer<typeof ZComplianceSettings>;
export const ZCourseCreateBase = z.object({
title: z.string().min(1),
description: z.string().min(1),
type: z.enum(['LIVE_CLASS', 'SELF_PACED', 'COMPLIANCE']),
type: z.enum(['LIVE_CLASS', 'SELF_PACED', 'COMPLIANCE', 'PUBLIC']),
organizationId: z.string().min(1),
compliance: ZComplianceSettings.optional()
});
@@ -333,7 +334,7 @@ export type TCertificationSettings = z.infer<typeof ZCertificationSettings>;
export const ZCourseUpdateBase = z.object({
title: z.string().min(1).optional(),
description: z.string().min(1).optional(),
type: z.enum(['LIVE_CLASS', 'SELF_PACED', 'COMPLIANCE']).optional(),
type: z.enum(['LIVE_CLASS', 'SELF_PACED', 'COMPLIANCE', 'PUBLIC']).optional(),
logo: z.string().optional(),
slug: z.string().optional(),
isPublished: z.boolean().optional(),
@@ -343,7 +344,8 @@ export const ZCourseUpdateBase = z.object({
currency: z.enum(['NGN', 'USD']).optional(),
certificate: ZCertificationSettings.optional(),
tagIds: z.array(z.uuid()).max(100).optional(),
compliance: ZComplianceSettings.optional()
compliance: ZComplianceSettings.optional(),
callout: ZCourseCalloutInput.optional()
});
export const ZCourseUpdate = ZCourseUpdateBase.refine(
@@ -6,7 +6,9 @@
*/
export * from './course';
export * from './callout';
export * from './compliance';
export * from './public-course';
export * from './section';
export * from './invite';
export { ZCourseGetParam, type TCourseGetParam } from './course';
@@ -0,0 +1,19 @@
import * as z from 'zod';
import { ZSlug } from '../shared/slug';
/**
* Route params + query schemas for the anonymous, SEO-indexable public course
* surface under `(org-site)/course/[courseSlug]`.
*/
export const ZPublicCourseBySlugParam = z.object({
courseSlug: ZSlug
});
export type TPublicCourseBySlugParam = z.infer<typeof ZPublicCourseBySlugParam>;
export const ZPublicCourseItemParam = z.object({
courseSlug: ZSlug,
itemSlug: ZSlug
});
export type TPublicCourseItemParam = z.infer<typeof ZPublicCourseItemParam>;
@@ -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>;
+1
View File
@@ -1,4 +1,5 @@
export * from './constants';
export * from './shared';
export * from './account';
export * from './agent';
@@ -1,5 +1,7 @@
import * as z from 'zod';
import { ZSlug } from '../shared/slug';
// Lesson Schemas
export const ZLessonCreate = z.object({
title: z.string().min(1),
@@ -10,7 +12,8 @@ export const ZLessonCreate = z.object({
lessonAt: z.string().optional(),
teacherId: z.string().optional(),
isUnlocked: z.boolean().optional(),
public: z.boolean().optional()
public: z.boolean().optional(),
slug: ZSlug.optional()
});
export type TLessonCreate = z.infer<typeof ZLessonCreate>;
@@ -24,6 +27,7 @@ export const ZLessonUpdate = z.object({
teacherId: z.string().optional(),
isUnlocked: z.boolean().optional(),
public: z.boolean().optional(),
slug: ZSlug.optional(),
isComplete: z.boolean().optional(),
videoUrl: z.url().optional(),
slideUrl: z.url().optional(),
@@ -0,0 +1 @@
export * from './slug';
@@ -0,0 +1,57 @@
import * as z from 'zod';
/**
* Slug format used by public-course URLs (and shared by `lesson.slug` and `exercise.slug`).
* Kebab-case, 1–80 chars, lowercase alphanumerics with single `-` separators, no leading/trailing dash.
*/
export const SLUG_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
export const ZSlug = z
.string()
.min(1, { message: 'Slug is required' })
.max(80, { message: 'Slug must be 80 characters or fewer' })
.regex(SLUG_REGEX, {
message: 'Slug must be lowercase letters, numbers, and single dashes'
});
export type TSlug = z.infer<typeof ZSlug>;
/**
* Generate a kebab-case slug from a free-form title. Does not enforce uniqueness;
* callers must resolve collisions against the target namespace (e.g. per-course).
*/
export function slugifyTitle(title: string): string {
const normalized = title
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80)
.replace(/-+$/g, '');
return normalized || 'untitled';
}
/**
* Resolve a slug against a list of already-taken slugs by appending `-2`, `-3`, ...
* until a free value is found. Respects the 80-char cap by trimming the base slug
* as needed to fit the suffix.
*/
export function resolveSlugCollision(baseSlug: string, takenSlugs: Iterable<string>): string {
const taken = new Set(takenSlugs);
if (!taken.has(baseSlug)) {
return baseSlug;
}
let counter = 2;
while (true) {
const suffix = `-${counter}`;
const trimmedBase = baseSlug.slice(0, 80 - suffix.length).replace(/-+$/g, '') || 'untitled';
const candidate = `${trimmedBase}${suffix}`;
if (!taken.has(candidate)) {
return candidate;
}
counter += 1;
}
}