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
+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;
}
}