feat: enhance API and middleware functionality: add public API routes, implement program and course member checks, and improve rate limiting for invite acceptance. Update OpenAPI spec generation to focus on public API endpoints and add new middleware for automation key scopes. Include new documentation for lesson AI features and public API tests.

This commit is contained in:
Rotimi Best
2026-04-12 08:58:41 +01:00
parent 5a8f50f67b
commit d6e812d08b
573 changed files with 31964 additions and 8708 deletions
@@ -1,7 +1,8 @@
import * as z from 'zod';
const AssetKind = z.enum(['video', 'document', 'image', 'audio', 'other']);
const AssetProvider = z.enum(['upload', 'youtube', 'generic', 'external_url']);
const AssetProvider = z.enum(['upload', 'youtube', 'generic', 'external_url', 'google_drive']);
export type TAssetProvider = z.infer<typeof AssetProvider>;
const AssetStatus = z.enum(['active', 'archived']);
const AssetTargetType = z.enum(['lesson', 'exercise', 'question']);
@@ -228,6 +228,7 @@ export const ZCourseImportDraftPublish = z.object({
export type TCourseImportDraftPublish = z.infer<typeof ZCourseImportDraftPublish>;
export const ZCourseImportDraftPublishToCourse = ZCourseImportDraftPublish.extend({
courseId: z.string().min(1)
courseId: z.string().min(1),
syncMode: z.enum(['merge', 'replace']).default('merge')
});
export type TCourseImportDraftPublishToCourse = z.infer<typeof ZCourseImportDraftPublishToCourse>;
@@ -1,5 +1,7 @@
import * as z from 'zod';
import { getStarRatingMaxFromSettings, isValidStarRatingValue } from '@cio/question-types';
import { QUESTION_TYPE } from '../constants';
const EXERCISE_QUESTION_TYPE_ID_LITERALS = [
@@ -14,16 +16,20 @@ const EXERCISE_QUESTION_TYPE_ID_LITERALS = [
z.literal(QUESTION_TYPE.MATCHING),
z.literal(QUESTION_TYPE.ORDERING),
z.literal(QUESTION_TYPE.HOTSPOT),
z.literal(QUESTION_TYPE.LINK)
z.literal(QUESTION_TYPE.LINK),
z.literal(QUESTION_TYPE.WORD_BANK),
z.literal(QUESTION_TYPE.STAR)
] as const;
export const ZExerciseQuestionTypeId = z.union(EXERCISE_QUESTION_TYPE_ID_LITERALS);
export type TExerciseQuestionTypeId = z.infer<typeof ZExerciseQuestionTypeId>;
const QUESTION_VALIDATION_RULES: Record<
number,
Array<(question: { options?: Array<{ isCorrect: boolean }> }) => string | null>
> = {
type QuestionRuleInput = {
options?: Array<{ isCorrect: boolean }>;
settings?: Record<string, unknown>;
};
const QUESTION_VALIDATION_RULES: Record<number, Array<(question: QuestionRuleInput) => string | null>> = {
[QUESTION_TYPE.RADIO]: [
(question) => {
const options = question.options || [];
@@ -72,7 +78,41 @@ const QUESTION_VALIDATION_RULES: Record<
return null;
}
],
[QUESTION_TYPE.TEXTAREA]: []
[QUESTION_TYPE.TEXTAREA]: [],
[QUESTION_TYPE.WORD_BANK]: [
(question) => {
const settings = question.settings;
const template = String(settings?.template ?? '');
const blankCount = Math.max(0, template.split('___').length - 1);
if (blankCount < 1) {
return 'Word bank questions need at least one ___ blank in the template';
}
const raw = settings?.correctAnswers;
const answers = Array.isArray(raw) ? (raw as unknown[]) : [];
if (answers.length < blankCount) {
return 'Provide a correct answer for each blank';
}
for (let i = 0; i < blankCount; i++) {
if (!String(answers[i] ?? '').trim()) {
return `Correct answer for blank ${i + 1} is required`;
}
}
return null;
}
],
[QUESTION_TYPE.STAR]: [
(question) => {
const settings = question.settings ?? {};
const maxStars = getStarRatingMaxFromSettings(settings);
const rawCorrect = settings.correctValue;
const correct = typeof rawCorrect === 'number' ? rawCorrect : rawCorrect != null ? Number(rawCorrect) : NaN;
if (!isValidStarRatingValue(correct, maxStars)) {
return 'Set a correct star value from 1 up to the maximum number of stars';
}
return null;
}
]
};
// Base schema for exercise update question (without refinement)
+2
View File
@@ -12,6 +12,8 @@ export * from './media';
export * from './newsfeed';
export * from './onboarding';
export * from './organization';
export * from './public-api';
export * from './submission';
export * from './dash';
export * from './tag';
export * from './program';
@@ -30,7 +30,7 @@ export const ZLessonUpdate = z.object({
videos: z
.array(
z.object({
type: z.enum(['youtube', 'generic', 'upload']),
type: z.enum(['youtube', 'generic', 'upload', 'google_drive']),
link: z.string(),
key: z.string().optional(),
assetId: z.string().uuid().optional(),
@@ -18,17 +18,25 @@ export type TGetAudienceQuery = z.infer<typeof ZGetAudienceQuery>;
export const ZImportAudienceMembers = z.object({
recipientCsv: z.string().max(25000),
courseIds: z.array(z.string().uuid()).optional(),
programIds: z.array(z.string().uuid()).optional(),
allCourses: z.boolean().optional().default(false),
allPrograms: z.boolean().optional().default(false),
sendEmail: z.boolean().default(true)
});
export type TImportAudienceMembers = z.infer<typeof ZImportAudienceMembers>;
export const ZAssignAudienceCourses = z.object({
profileIds: z.array(z.uuid()).min(1).max(500),
courseIds: z.array(z.uuid()).min(1),
sendEmail: z.boolean().default(true)
});
export const ZAssignAudienceCourses = z
.object({
profileIds: z.array(z.uuid()).min(1).max(500),
courseIds: z.array(z.uuid()).optional(),
programIds: z.array(z.uuid()).optional(),
sendEmail: z.boolean().default(true)
})
.refine((data) => (data.courseIds?.length ?? 0) > 0 || (data.programIds?.length ?? 0) > 0, {
message: 'At least one course or program must be selected',
path: ['courseIds']
});
export type TAssignAudienceCourses = z.infer<typeof ZAssignAudienceCourses>;
@@ -18,6 +18,9 @@ export const ZGetCoursesBySiteName = z.object({
export type TGetCoursesBySiteName = z.infer<typeof ZGetCoursesBySiteName>;
export const ZGetOrganizationCoursesQuery = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
search: z.string().trim().optional(),
tags: z.string().optional()
});
@@ -0,0 +1 @@
export * from './program';
@@ -0,0 +1,71 @@
import * as z from 'zod';
import { ROLE } from '@cio/utils/constants';
export const ZCreateProgram = z.object({
name: z.string().min(1).max(255),
description: z.string().max(2000).optional(),
coverImage: z.string().url().optional()
});
export type TCreateProgram = z.infer<typeof ZCreateProgram>;
export const ZUpdateProgram = z.object({
name: z.string().min(1).max(255).optional(),
description: z.string().max(2000).nullable().optional(),
coverImage: z.string().url().nullable().optional(),
status: z.enum(['ACTIVE', 'INACTIVE', 'ARCHIVED']).optional()
});
export type TUpdateProgram = z.infer<typeof ZUpdateProgram>;
export const ZAddCourseToProgram = z.object({
courseId: z.string().uuid()
});
export type TAddCourseToProgram = z.infer<typeof ZAddCourseToProgram>;
export const ZAddProgramMembers = z.object({
members: z
.array(
z.object({
profileId: z.string().uuid().optional(),
email: z.string().email().optional(),
name: z.string().optional(),
roleId: z.union([z.literal(ROLE.TUTOR), z.literal(ROLE.STUDENT)])
})
)
.min(1)
.refine((members) => members.every((member) => member.profileId || member.email), {
message: 'Each member must include a profileId or email'
})
});
export type TAddProgramMembers = z.infer<typeof ZAddProgramMembers>;
export const ZUpdateProgramMember = z.object({
roleId: z.union([z.literal(ROLE.TUTOR), z.literal(ROLE.STUDENT)])
});
export type TUpdateProgramMember = z.infer<typeof ZUpdateProgramMember>;
export const ZCreateProgramNewsfeed = z.object({
content: z.string().min(1),
isPinned: z.boolean().optional()
});
export type TCreateProgramNewsfeed = z.infer<typeof ZCreateProgramNewsfeed>;
export const ZUpdateProgramNewsfeed = z.object({
content: z.string().min(1).optional(),
isPinned: z.boolean().optional()
});
export type TUpdateProgramNewsfeed = z.infer<typeof ZUpdateProgramNewsfeed>;
export const ZUpdateProgramReaction = z.object({
reaction: z.object({
clap: z.array(z.string()),
smile: z.array(z.string()),
thumbsup: z.array(z.string()),
thumbsdown: z.array(z.string())
})
});
export type TUpdateProgramReaction = z.infer<typeof ZUpdateProgramReaction>;
export const ZCreateProgramNewsfeedComment = z.object({
content: z.string().min(1)
});
export type TCreateProgramNewsfeedComment = z.infer<typeof ZCreateProgramNewsfeedComment>;
@@ -0,0 +1,27 @@
import * as z from 'zod';
import { ZAssignAudienceCourses, ZGetAudienceQuery } from '../organization';
export const ZPublicApiAudienceQuery = ZGetAudienceQuery;
export type TPublicApiAudienceQuery = z.infer<typeof ZPublicApiAudienceQuery>;
export const ZPublicApiCreateAudience = z.object({
email: z.string().email(),
courseIds: z.array(z.string().uuid()).optional(),
sendEmail: z.boolean().default(true)
});
export type TPublicApiCreateAudience = z.infer<typeof ZPublicApiCreateAudience>;
export const ZPublicApiUpdateAudience = z.object({
email: z.string().email(),
sendEmail: z.boolean().default(true)
});
export type TPublicApiUpdateAudience = z.infer<typeof ZPublicApiUpdateAudience>;
export const ZPublicApiAssignAudienceCourses = ZAssignAudienceCourses;
export type TPublicApiAssignAudienceCourses = z.infer<typeof ZPublicApiAssignAudienceCourses>;
export const ZPublicApiAudienceMemberParam = z.object({
memberId: z.coerce.number().int().positive()
});
export type TPublicApiAudienceMemberParam = z.infer<typeof ZPublicApiAudienceMemberParam>;
@@ -0,0 +1,33 @@
import * as z from 'zod';
import { ZCourseCreate, ZCourseUpdate } from '../course/course';
import { ZCourseImportDraftPayload } from '../course-import/course-import';
export const ZPublicApiCoursesQuery = z.object({
tags: z.string().optional()
});
export type TPublicApiCoursesQuery = z.infer<typeof ZPublicApiCoursesQuery>;
export const ZPublicApiCourseParam = z.object({
courseId: z.string().uuid()
});
export type TPublicApiCourseParam = z.infer<typeof ZPublicApiCourseParam>;
export const ZPublicApiCreateCourse = ZCourseCreate.omit({
organizationId: true
});
export type TPublicApiCreateCourse = z.infer<typeof ZPublicApiCreateCourse>;
export const ZPublicApiUpdateCourse = ZCourseUpdate.omit({
tagIds: true
});
export type TPublicApiUpdateCourse = z.infer<typeof ZPublicApiUpdateCourse>;
export const ZPublicApiUpdateCourseStructure = z.object({
mode: z.enum(['merge', 'replace']).default('merge'),
idempotencyKey: z.string().min(1).optional(),
summary: z.record(z.string(), z.unknown()).optional(),
sourceArtifacts: z.array(z.record(z.string(), z.unknown())).optional(),
draft: ZCourseImportDraftPayload
});
export type TPublicApiUpdateCourseStructure = z.infer<typeof ZPublicApiUpdateCourseStructure>;
@@ -0,0 +1,2 @@
export * from './audience';
export * from './course';