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
+156
View File
@@ -0,0 +1,156 @@
/**
* Wire `code` values returned in API JSON error bodies (`success: false`).
* Shared by `@cio/api` and clients (e.g. dashboard snackbar mapping).
*/
export const ErrorCodes = {
// Common errors
NOT_FOUND: 'NOT_FOUND',
UNAUTHORIZED: 'UNAUTHORIZED',
// Dashboard Analytics errors
ORG_ANALYTICS_FETCH_FAILED: 'ORG_ANALYTICS_FETCH_FAILED',
NO_ORG_ID_PROVIDED: 'NO_ORG_ID_PROVIDED',
// Community errors
UPDATE_COMMUNITY_QUESTION_FAILED: 'UPDATE_COMMUNITY_QUESTION_FAILED',
ADD_COMMUNITY_QUESTION_FAILED: 'ADD_COMMUNITY_QUESTION_FAILED',
COMMUNITY_QUESTIONS_FETCH_FAILED: 'COMMUNITY_QUESTIONS_FETCH_FAILED',
COMMUNITY_QUESTION_NOT_FOUND: 'COMMUNITY_QUESTION_NOT_FOUND',
COMMENT_SUBMISSION_FAILED: 'COMMENT_SUBMISSION_FAILED',
UPVOTE_FAILED: 'UPVOTE_FAILED',
DELETE_COMMENT_FAILED: 'DELETE_COMMENT_FAILED',
DELETE_QUESTION_FAILED: 'DELETE_QUESTION_FAILED',
// Organization errors
SITENAME_EXISTS: 'SITENAME_EXISTS',
ORG_CREATE_FAILED: 'ORG_CREATE_FAILED',
ORG_NOT_FOUND: 'ORG_NOT_FOUND',
ORGANIZATION_NOT_FOUND: 'ORGANIZATION_NOT_FOUND',
ORG_TEAM_FETCH_FAILED: 'ORG_TEAM_FETCH_FAILED',
ORG_AUDIENCE_FETCH_FAILED: 'ORG_AUDIENCE_FETCH_FAILED',
COURSES_FETCH_FAILED: 'COURSES_FETCH_FAILED',
ORG_PLAN_CREATE_FAILED: 'ORG_PLAN_CREATE_FAILED',
ORG_PLAN_UPDATE_FAILED: 'ORG_PLAN_UPDATE_FAILED',
ORG_PLAN_CANCEL_FAILED: 'ORG_PLAN_CANCEL_FAILED',
ORG_PLAN_NOT_FOUND: 'ORG_PLAN_NOT_FOUND',
ORG_TEAM_INVITE_FAILED: 'ORG_TEAM_INVITE_FAILED',
ORG_TEAM_REMOVE_FAILED: 'ORG_TEAM_REMOVE_FAILED',
ORG_AUDIENCE_REMOVE_FAILED: 'ORG_AUDIENCE_REMOVE_FAILED',
ORG_TEAM_EMAIL_EXISTS: 'ORG_TEAM_EMAIL_EXISTS',
ORG_TEAM_NOT_AUTHORIZED: 'ORG_TEAM_NOT_AUTHORIZED',
ORGANIZATION_API_KEY_NOT_FOUND: 'ORGANIZATION_API_KEY_NOT_FOUND',
ORGANIZATION_API_KEY_CREATE_FAILED: 'ORGANIZATION_API_KEY_CREATE_FAILED',
ORGANIZATION_API_KEY_FETCH_FAILED: 'ORGANIZATION_API_KEY_FETCH_FAILED',
ORGANIZATION_API_KEY_REVOKE_FAILED: 'ORGANIZATION_API_KEY_REVOKE_FAILED',
ORGANIZATION_API_KEY_ROTATE_FAILED: 'ORGANIZATION_API_KEY_ROTATE_FAILED',
AUTOMATION_KEY_LIMIT_EXCEEDED: 'AUTOMATION_KEY_LIMIT_EXCEEDED',
AUTOMATION_CREDIT_LIMIT_EXCEEDED: 'AUTOMATION_CREDIT_LIMIT_EXCEEDED',
AUTOMATION_RATE_LIMIT_EXCEEDED: 'AUTOMATION_RATE_LIMIT_EXCEEDED',
TAG_GROUP_NOT_FOUND: 'TAG_GROUP_NOT_FOUND',
TAG_NOT_FOUND: 'TAG_NOT_FOUND',
TAG_FETCH_FAILED: 'TAG_FETCH_FAILED',
TAG_CREATE_FAILED: 'TAG_CREATE_FAILED',
TAG_UPDATE_FAILED: 'TAG_UPDATE_FAILED',
TAG_ASSIGNMENT_FAILED: 'TAG_ASSIGNMENT_FAILED',
// Asset errors
ASSET_NOT_FOUND: 'ASSET_NOT_FOUND',
ASSET_IN_USE: 'ASSET_IN_USE',
ASSET_USAGE_NOT_FOUND: 'ASSET_USAGE_NOT_FOUND',
ASSET_CREATE_FAILED: 'ASSET_CREATE_FAILED',
ASSET_FETCH_FAILED: 'ASSET_FETCH_FAILED',
ASSET_LIST_FAILED: 'ASSET_LIST_FAILED',
ASSET_UPDATE_FAILED: 'ASSET_UPDATE_FAILED',
ASSET_DELETE_FAILED: 'ASSET_DELETE_FAILED',
ASSET_ATTACH_FAILED: 'ASSET_ATTACH_FAILED',
ASSET_DETACH_FAILED: 'ASSET_DETACH_FAILED',
ASSET_USAGE_FETCH_FAILED: 'ASSET_USAGE_FETCH_FAILED',
ASSET_EXPORT_FAILED: 'ASSET_EXPORT_FAILED',
ASSET_STORAGE_FETCH_FAILED: 'ASSET_STORAGE_FETCH_FAILED',
// Profile errors
PROFILE_UPDATE_FAILED: 'PROFILE_UPDATE_FAILED',
PROFILE_NOT_FOUND: 'PROFILE_NOT_FOUND',
// Account errors
ACCOUNT_NOT_FOUND: 'ACCOUNT_NOT_FOUND',
// Auth errors
FORBIDDEN: 'FORBIDDEN',
// Course errors
COURSE_NOT_FOUND: 'COURSE_NOT_FOUND',
COURSE_FETCH_FAILED: 'COURSE_FETCH_FAILED',
COURSE_IMPORT_DRAFT_NOT_FOUND: 'COURSE_IMPORT_DRAFT_NOT_FOUND',
COURSE_IMPORT_CREATE_FAILED: 'COURSE_IMPORT_CREATE_FAILED',
COURSE_IMPORT_FETCH_FAILED: 'COURSE_IMPORT_FETCH_FAILED',
COURSE_IMPORT_UPDATE_FAILED: 'COURSE_IMPORT_UPDATE_FAILED',
COURSE_IMPORT_PUBLISH_FAILED: 'COURSE_IMPORT_PUBLISH_FAILED',
// Lesson errors
LESSON_NOT_FOUND: 'LESSON_NOT_FOUND',
LESSON_FETCH_FAILED: 'LESSON_FETCH_FAILED',
COURSE_SECTION_NOT_FOUND: 'COURSE_SECTION_NOT_FOUND',
// Exercise errors
EXERCISE_NOT_FOUND: 'EXERCISE_NOT_FOUND',
EXERCISE_FETCH_FAILED: 'EXERCISE_FETCH_FAILED',
// Submission errors
SUBMISSION_NOT_FOUND: 'SUBMISSION_NOT_FOUND',
SUBMISSION_FETCH_FAILED: 'SUBMISSION_FETCH_FAILED',
// Comment errors
COMMENT_NOT_FOUND: 'COMMENT_NOT_FOUND',
LESSON_COMMENT_UPDATE_FAILED: 'LESSON_COMMENT_UPDATE_FAILED',
NEWSFEED_COMMENT_UPDATE_FAILED: 'NEWSFEED_COMMENT_UPDATE_FAILED',
// Quiz errors
QUIZ_NOT_FOUND: 'QUIZ_NOT_FOUND',
QUIZ_FETCH_FAILED: 'QUIZ_FETCH_FAILED',
QUIZ_CREATE_FAILED: 'QUIZ_CREATE_FAILED',
QUIZ_UPDATE_FAILED: 'QUIZ_UPDATE_FAILED',
QUIZ_DELETE_FAILED: 'QUIZ_DELETE_FAILED',
// Lesson Language errors
LESSON_LANGUAGE_NOT_FOUND: 'LESSON_LANGUAGE_NOT_FOUND',
LESSON_LANGUAGE_FETCH_FAILED: 'LESSON_LANGUAGE_FETCH_FAILED',
LESSON_LANGUAGE_CREATE_FAILED: 'LESSON_LANGUAGE_CREATE_FAILED',
LESSON_LANGUAGE_UPDATE_FAILED: 'LESSON_LANGUAGE_UPDATE_FAILED',
LESSON_LANGUAGE_DELETE_FAILED: 'LESSON_LANGUAGE_DELETE_FAILED',
// SSO errors
SSO_REGISTRATION_FAILED: 'SSO_REGISTRATION_FAILED',
SSO_NOT_FOUND: 'SSO_NOT_FOUND',
SSO_NOT_ENABLED: 'SSO_NOT_ENABLED',
UPGRADE_REQUIRED: 'UPGRADE_REQUIRED',
CONFLICT: 'CONFLICT',
// Token exchange errors
TOKEN_EXCHANGE_INVALID_TOKEN: 'TOKEN_EXCHANGE_INVALID_TOKEN',
TOKEN_EXCHANGE_EXPIRED: 'TOKEN_EXCHANGE_EXPIRED',
TOKEN_EXCHANGE_ORG_NOT_FOUND: 'TOKEN_EXCHANGE_ORG_NOT_FOUND',
TOKEN_EXCHANGE_NOT_ENABLED: 'TOKEN_EXCHANGE_NOT_ENABLED',
// Token auth admin errors
TOKEN_AUTH_ALREADY_EXISTS: 'TOKEN_AUTH_ALREADY_EXISTS',
// Program errors
PROGRAM_NOT_FOUND: 'PROGRAM_NOT_FOUND',
PROGRAM_FORBIDDEN: 'PROGRAM_FORBIDDEN',
PROGRAM_CREATE_FAILED: 'PROGRAM_CREATE_FAILED',
PROGRAM_UPDATE_FAILED: 'PROGRAM_UPDATE_FAILED',
PROGRAM_DELETE_FAILED: 'PROGRAM_DELETE_FAILED',
COURSE_ALREADY_IN_PROGRAM: 'COURSE_ALREADY_IN_PROGRAM',
MEMBER_ALREADY_IN_PROGRAM: 'MEMBER_ALREADY_IN_PROGRAM',
PROGRAM_MEMBER_NOT_FOUND: 'PROGRAM_MEMBER_NOT_FOUND',
PROGRAM_NEWSFEED_NOT_FOUND: 'PROGRAM_NEWSFEED_NOT_FOUND',
PROGRAM_NEWSFEED_COMMENT_NOT_FOUND: 'PROGRAM_NEWSFEED_COMMENT_NOT_FOUND',
PROGRAM_MEMBER_CHECK_FAILED: 'PROGRAM_MEMBER_CHECK_FAILED',
// Generic
VALIDATION_ERROR: 'VALIDATION_ERROR',
INTERNAL_ERROR: 'INTERNAL_ERROR'
} as const;
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
+1
View File
@@ -1,3 +1,4 @@
export * from './roles';
export * from './org';
export * from './content';
export * from './error-codes';
+7 -5
View File
@@ -11,8 +11,9 @@ export default {
FEATURES: [
'Unlimited Courses',
'Unlimited Q/A',
'Unlimited Programs',
'20 Students',
'AI Course Builder (no video upload)',
'Advanced Course Builder (no video upload)',
'MCP access with 20 automation credits / month',
'ClassroomIO branding'
],
@@ -37,10 +38,11 @@ export default {
'Everything in Basic',
'Unlimited Collaborators',
'10K Students',
'Advanced AI Course Builder (Video upload & Certificate)',
'Course Builder with Video Upload & Certificate',
'MCP access with 500 automation credits / month',
'Custom Branding',
'Slack Invite',
'Custom Domain',
'API with limits',
'Includes all upcoming features'
],
CTA: {
@@ -65,8 +67,8 @@ export default {
FEATURES: [
'Everything in Early Adopters plus:',
'Unlimited students',
'Custom Domain',
'MCP access with 5,000 automation credits / month',
'Unlimited API requests',
'SSO',
'24/7 Support'
],
CTA: {
@@ -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';