chore: ai, public course and other ui improvements
This commit is contained in:
@@ -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,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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user