feat: implement automation feature

This commit is contained in:
Rotimi Best
2026-03-18 23:39:12 +01:00
parent 576d0366de
commit ef4424e267
121 changed files with 4544 additions and 1055 deletions
+130
View File
@@ -0,0 +1,130 @@
import { PLAN } from './constants';
export const AUTOMATION_TYPE = {
MCP: 'mcp',
API: 'api',
ZAPIER: 'zapier'
} as const;
export const AUTOMATION_USAGE_CATEGORY = {
READ: 'read',
WRITE: 'write',
PUBLISH: 'publish'
} as const;
export const MCP_TOOL_CREDIT_COST = {
list_org_courses: 0,
get_course_structure: 0,
get_course_draft: 0,
list_course_exercises: 0,
get_course_exercise: 0,
create_course_draft: 1,
create_course_draft_from_course: 1,
reorder_course_content: 1,
update_course_landing_page: 1,
update_course_draft: 1,
tag_course_draft: 1,
tag_courses: 1,
create_course_exercise: 1,
create_course_exercise_from_template: 1,
update_course_exercise: 1,
publish_course_draft: 5,
publish_course_draft_to_existing_course: 5
} as const;
export type TMcpToolName = keyof typeof MCP_TOOL_CREDIT_COST;
export type TAutomationUsageCategory = (typeof AUTOMATION_USAGE_CATEGORY)[keyof typeof AUTOMATION_USAGE_CATEGORY];
export type McpAutomationLimits = {
monthlyCredits: number;
maxActiveKeys: number;
rateLimits: {
perKey: {
readPerMinute: number;
writePerMinute: number;
publishPerMinute: number;
};
perOrg: {
readPerMinute: number;
writePerMinute: number;
publishPerMinute: number;
};
};
};
export const MCP_AUTOMATION_LIMITS_BY_PLAN: Record<string, McpAutomationLimits> = {
[PLAN.BASIC]: {
monthlyCredits: 20,
maxActiveKeys: 1,
rateLimits: {
perKey: {
readPerMinute: 30,
writePerMinute: 10,
publishPerMinute: 1
},
perOrg: {
readPerMinute: 60,
writePerMinute: 20,
publishPerMinute: 2
}
}
},
[PLAN.EARLY_ADOPTER]: {
monthlyCredits: 500,
maxActiveKeys: 5,
rateLimits: {
perKey: {
readPerMinute: 60,
writePerMinute: 20,
publishPerMinute: 3
},
perOrg: {
readPerMinute: 120,
writePerMinute: 40,
publishPerMinute: 5
}
}
},
[PLAN.ENTERPRISE]: {
monthlyCredits: 5000,
maxActiveKeys: 25,
rateLimits: {
perKey: {
readPerMinute: 180,
writePerMinute: 60,
publishPerMinute: 10
},
perOrg: {
readPerMinute: 360,
writePerMinute: 120,
publishPerMinute: 20
}
}
}
};
export function getMcpAutomationCategory(toolName: TMcpToolName): TAutomationUsageCategory {
if (
toolName === 'get_course_structure' ||
toolName === 'get_course_draft' ||
toolName === 'list_org_courses' ||
toolName === 'list_course_exercises' ||
toolName === 'get_course_exercise'
) {
return AUTOMATION_USAGE_CATEGORY.READ;
}
if (toolName === 'publish_course_draft' || toolName === 'publish_course_draft_to_existing_course') {
return AUTOMATION_USAGE_CATEGORY.PUBLISH;
}
return AUTOMATION_USAGE_CATEGORY.WRITE;
}
export function getMcpAutomationLimits(planName: string | null | undefined): McpAutomationLimits {
if (!planName) {
return MCP_AUTOMATION_LIMITS_BY_PLAN[PLAN.BASIC];
}
return MCP_AUTOMATION_LIMITS_BY_PLAN[planName] ?? MCP_AUTOMATION_LIMITS_BY_PLAN[PLAN.BASIC];
}
+9 -1
View File
@@ -13,6 +13,7 @@ export default {
'Unlimited Q/A',
'20 Students',
'AI Course Builder (no video upload)',
'MCP access with 20 automation credits / month',
'ClassroomIO branding'
],
CTA: {
@@ -37,6 +38,7 @@ export default {
'Unlimited Collaborators',
'10K Students',
'Advanced AI Course Builder (Video upload & Certificate)',
'MCP access with 500 automation credits / month',
'Custom Branding',
'Slack Invite',
'Includes all upcoming features'
@@ -60,7 +62,13 @@ export default {
YEARLY: 'Request Pricing',
IS_PREMIUM: true
},
FEATURES: ['Everything in Early Adopters plus:', 'Unlimited students', 'Custom Domain', '24/7 Support'],
FEATURES: [
'Everything in Early Adopters plus:',
'Unlimited students',
'Custom Domain',
'MCP access with 5,000 automation credits / month',
'24/7 Support'
],
CTA: {
LABEL: 'Contact Us',
LINK: 'https://cal.com/classroomio/enterprise',
+1
View File
@@ -1,4 +1,5 @@
export * from './constants';
export * from './automation';
export * from './utils';
import PLANS from './data';
@@ -3,6 +3,8 @@ import * as z from 'zod';
import { ZCourseMetadata } from '../course/course';
const ZSupportedLocale = z.enum(['en', 'hi', 'fr', 'pt', 'de', 'vi', 'ru', 'es', 'pl', 'da']);
const LESSON_BODY_HTML_DESCRIPTION =
'Lesson body HTML only. Do not include the lesson title. Do not use h1 or h2 anywhere in lessonLanguages[].content. Start headings at h3 because that is the highest heading level allowed in lesson content.';
export const ZCourseImportWarning = z.object({
code: z.string().min(1),
@@ -12,7 +14,7 @@ export const ZCourseImportWarning = z.object({
export type TCourseImportWarning = z.infer<typeof ZCourseImportWarning>;
export const ZCourseImportSourceReference = z.object({
type: z.enum(['prompt', 'pdf']),
type: z.enum(['prompt', 'pdf', 'course']),
label: z.string().min(1),
pageStart: z.number().int().min(1).optional(),
pageEnd: z.number().int().min(1).optional()
@@ -48,13 +50,14 @@ export type TCourseImportDraftLesson = z.infer<typeof ZCourseImportDraftLesson>;
export const ZCourseImportDraftLessonLanguage = z.object({
lessonExternalId: z.string().min(1),
locale: ZSupportedLocale,
content: z.string().min(1)
content: z.string().min(1).describe(LESSON_BODY_HTML_DESCRIPTION)
});
export type TCourseImportDraftLessonLanguage = z.infer<typeof ZCourseImportDraftLessonLanguage>;
export const ZCourseImportDraftPayload = z
.object({
course: ZCourseImportDraftCourse,
tags: z.array(z.string().trim().min(1).max(80)).max(100).default([]),
sections: z.array(ZCourseImportDraftSection).min(1),
lessons: z.array(ZCourseImportDraftLesson).min(1),
lessonLanguages: z.array(ZCourseImportDraftLessonLanguage).min(1),
@@ -63,6 +66,19 @@ export const ZCourseImportDraftPayload = z
warnings: z.array(ZCourseImportWarning).default([])
})
.superRefine((value, ctx) => {
const normalizedTags = new Set<string>();
value.tags.forEach((tag, index) => {
const tagKey = tag.trim().toLowerCase();
if (normalizedTags.has(tagKey)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['tags', index],
message: 'Draft tags must be unique'
});
}
normalizedTags.add(tagKey);
});
const sectionIds = new Set<string>();
value.sections.forEach((section, index) => {
if (sectionIds.has(section.externalId)) {
@@ -108,7 +124,7 @@ export const ZCourseImportDraftPayload = z
export type TCourseImportDraftPayload = z.infer<typeof ZCourseImportDraftPayload>;
export const ZCourseImportDraftCreate = z.object({
sourceType: z.enum(['prompt', 'pdf']),
sourceType: z.enum(['prompt', 'pdf', 'course']),
idempotencyKey: z.string().min(1).optional(),
summary: z.record(z.string(), z.unknown()).optional(),
sourceArtifacts: z.array(z.record(z.string(), z.unknown())).optional(),
@@ -116,6 +132,19 @@ export const ZCourseImportDraftCreate = z.object({
});
export type TCourseImportDraftCreate = z.infer<typeof ZCourseImportDraftCreate>;
export const ZCourseImportCourseParam = z.object({
courseId: z.string().min(1)
});
export type TCourseImportCourseParam = z.infer<typeof ZCourseImportCourseParam>;
export const ZCourseImportDraftCreateFromCourse = z.object({
courseId: z.string().min(1),
idempotencyKey: z.string().min(1).optional(),
summary: z.record(z.string(), z.unknown()).optional(),
sourceArtifacts: z.array(z.record(z.string(), z.unknown())).optional()
});
export type TCourseImportDraftCreateFromCourse = z.infer<typeof ZCourseImportDraftCreateFromCourse>;
export const ZCourseImportDraftGetParam = z.object({
draftId: z.string().uuid()
});
@@ -133,6 +162,14 @@ export const ZCourseImportDraftPublish = z.object({
title: z.string().min(1).optional(),
description: z.string().min(1).optional(),
type: z.enum(['LIVE_CLASS', 'SELF_PACED']).optional(),
metadata: ZCourseMetadata.optional()
metadata: ZCourseMetadata.optional(),
bannerImageUrl: z.string().url().optional(),
bannerImageQuery: z.string().min(1).max(120).optional(),
generateBannerImage: z.boolean().optional()
});
export type TCourseImportDraftPublish = z.infer<typeof ZCourseImportDraftPublish>;
export const ZCourseImportDraftPublishToCourse = ZCourseImportDraftPublish.extend({
courseId: z.string().min(1)
});
export type TCourseImportDraftPublishToCourse = z.infer<typeof ZCourseImportDraftPublishToCourse>;
+138 -41
View File
@@ -114,6 +114,69 @@ export const ZCourseContentUpdate = z.object({
});
export type TCourseContentUpdate = z.infer<typeof ZCourseContentUpdate>;
export const ZCourseContentReorderSection = z.object({
id: z.string().min(1),
order: z.number().int().min(0)
});
export type TCourseContentReorderSection = z.infer<typeof ZCourseContentReorderSection>;
export const ZCourseContentReorderItem = ZCourseContentUpdateItem.omit({
isUnlocked: true
});
export type TCourseContentReorderItem = z.infer<typeof ZCourseContentReorderItem>;
export const ZCourseContentReorderBase = z.object({
sections: z.array(ZCourseContentReorderSection).min(1).optional(),
items: z.array(ZCourseContentReorderItem).min(1).optional()
});
export type TCourseContentReorderBase = z.infer<typeof ZCourseContentReorderBase>;
export const ZCourseContentReorder = ZCourseContentReorderBase.superRefine((data, ctx) => {
if (!data.sections?.length && !data.items?.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['sections'],
message: 'Provide sections or items to reorder'
});
}
const sectionIds = new Set<string>();
data.sections?.forEach((section, index) => {
if (sectionIds.has(section.id)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['sections', index, 'id'],
message: 'Section IDs must be unique'
});
}
sectionIds.add(section.id);
});
const itemKeys = new Set<string>();
data.items?.forEach((item, index) => {
if (item.order === undefined && item.sectionId === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['items', index],
message: 'Each item must provide order or sectionId'
});
}
const itemKey = `${item.type}:${item.id}`;
if (itemKeys.has(itemKey)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['items', index, 'id'],
message: 'Item IDs must be unique per content type'
});
}
itemKeys.add(itemKey);
});
});
export type TCourseContentReorder = z.infer<typeof ZCourseContentReorder>;
export const ZCourseContentDeleteItem = z.object({
id: z.string().min(1),
type: z.enum(['LESSON', 'EXERCISE'])
@@ -139,6 +202,40 @@ export const ZCourseCreate = z.object({
});
export type TCourseCreate = z.infer<typeof ZCourseCreate>;
export const ZCourseReward = z.object({
show: z.boolean(),
description: z.string()
});
export const ZCourseInstructor = z.object({
name: z.string(),
role: z.string(),
coursesNo: z.number(),
description: z.string(),
imgUrl: z.string()
});
export const ZCourseCertificate = z.object({
templateUrl: z.string()
});
export const ZCourseReview = z.object({
id: z.number(),
hide: z.boolean(),
name: z.string(),
avatar_url: z.string(),
rating: z.number(),
created_at: z.number(),
description: z.string()
});
export const ZCourseLessonTabsOrder = z.array(
z.object({
id: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]),
name: z.string()
})
);
// Course metadata schema matching the database structure
export const ZCourseMetadata = z.object({
requirements: z.string().optional(),
@@ -148,47 +245,11 @@ export const ZCourseMetadata = z.object({
showDiscount: z.boolean().optional(),
discount: z.number().optional(),
paymentLink: z.string().optional(),
reward: z
.object({
show: z.boolean(),
description: z.string()
})
.optional(),
instructor: z
.object({
name: z.string(),
role: z.string(),
coursesNo: z.number(),
description: z.string(),
imgUrl: z.string()
})
.optional(),
certificate: z
.object({
templateUrl: z.string()
})
.optional(),
reviews: z
.array(
z.object({
id: z.number(),
hide: z.boolean(),
name: z.string(),
avatar_url: z.string(),
rating: z.number(),
created_at: z.number(),
description: z.string()
})
)
.optional(),
lessonTabsOrder: z
.array(
z.object({
id: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]),
name: z.string()
})
)
.optional(),
reward: ZCourseReward.optional(),
instructor: ZCourseInstructor.optional(),
certificate: ZCourseCertificate.optional(),
reviews: z.array(ZCourseReview).optional(),
lessonTabsOrder: ZCourseLessonTabsOrder.optional(),
grading: z.boolean().optional(),
lessonDownload: z.boolean().optional(),
allowNewStudent: z.boolean(),
@@ -197,6 +258,27 @@ export const ZCourseMetadata = z.object({
});
export type TCourseMetadata = z.infer<typeof ZCourseMetadata>;
export const ZCourseLandingPageMetadataUpdate = z.object({
requirements: z.string().optional(),
description: z.string().optional(),
goals: z.string().optional(),
videoUrl: z.string().optional(),
showDiscount: z.boolean().optional(),
discount: z.number().optional(),
paymentLink: z.string().optional(),
reward: ZCourseReward.partial().optional(),
instructor: ZCourseInstructor.partial().optional(),
certificate: ZCourseCertificate.partial().optional(),
reviews: z.array(ZCourseReview).optional(),
lessonTabsOrder: ZCourseLessonTabsOrder.optional(),
grading: z.boolean().optional(),
lessonDownload: z.boolean().optional(),
allowNewStudent: z.boolean().optional(),
sectionDisplay: z.record(z.string(), z.boolean()).optional(),
isContentGroupingEnabled: z.boolean().optional()
});
export type TCourseLandingPageMetadataUpdate = z.infer<typeof ZCourseLandingPageMetadataUpdate>;
export const ZCourseUpdate = z.object({
title: z.string().min(1).optional(),
description: z.string().min(1).optional(),
@@ -206,6 +288,8 @@ export const ZCourseUpdate = z.object({
isPublished: z.boolean().optional(),
overview: z.string().optional(),
metadata: ZCourseMetadata.optional(),
cost: z.number().int().min(0).optional(),
currency: z.enum(['NGN', 'USD']).optional(),
isCertificateDownloadable: z.boolean().optional(),
certificateTheme: z.string().optional(),
tagIds: z.array(z.uuid()).max(100).optional()
@@ -217,6 +301,19 @@ export const ZCourseUpdateParam = z.object({
});
export type TCourseUpdateParam = z.infer<typeof ZCourseUpdateParam>;
export const ZCourseLandingPageUpdate = z.object({
title: z.string().min(1).optional(),
description: z.string().min(1).optional(),
overview: z.string().optional(),
cost: z.number().int().min(0).optional(),
currency: z.enum(['NGN', 'USD']).optional(),
imageUrl: z.url().optional(),
generateImage: z.boolean().optional(),
imageQuery: z.string().min(1).max(120).optional(),
metadata: ZCourseLandingPageMetadataUpdate.optional()
});
export type TCourseLandingPageUpdate = z.infer<typeof ZCourseLandingPageUpdate>;
export const ZCourseDeleteParam = z.object({
courseId: z.string().min(1)
});
@@ -8,6 +8,11 @@ export const ZOrganizationApiKeyScope = z.enum([
'course_import:draft:read',
'course_import:draft:update',
'course_import:draft:publish',
'course:read',
'course:write',
'course:tag:write',
'course:exercise:read',
'course:exercise:write',
'public_api:*'
]);
export type TOrganizationApiKeyScope = z.infer<typeof ZOrganizationApiKeyScope>;
@@ -17,6 +22,11 @@ export const ZListOrganizationApiKeysQuery = z.object({
});
export type TListOrganizationApiKeysQuery = z.infer<typeof ZListOrganizationApiKeysQuery>;
export const ZOrganizationAutomationUsageQuery = z.object({
type: ZOrganizationApiKeyType.default('mcp')
});
export type TOrganizationAutomationUsageQuery = z.infer<typeof ZOrganizationAutomationUsageQuery>;
export const ZCreateOrganizationApiKey = z.object({
type: ZOrganizationApiKeyType,
label: z.string().min(1).max(120).optional(),
+23
View File
@@ -67,3 +67,26 @@ export const ZCourseTagAssignment = z.object({
tagIds: z.array(z.uuid()).max(100).default([])
});
export type TCourseTagAssignment = z.infer<typeof ZCourseTagAssignment>;
export const ZAutomationTagMode = z.enum(['merge', 'replace']).default('merge');
export type TAutomationTagMode = z.infer<typeof ZAutomationTagMode>;
export const ZAutomationTagName = z.string().trim().min(1).max(80);
export type TAutomationTagName = z.infer<typeof ZAutomationTagName>;
export const ZAutomationDraftTagParam = z.object({
draftId: z.string().uuid()
});
export type TAutomationDraftTagParam = z.infer<typeof ZAutomationDraftTagParam>;
export const ZAutomationDraftTagAssignment = z.object({
tagNames: z.array(ZAutomationTagName).min(1).max(100),
mode: ZAutomationTagMode,
groupName: z.string().trim().min(1).max(80).optional()
});
export type TAutomationDraftTagAssignment = z.infer<typeof ZAutomationDraftTagAssignment>;
export const ZAutomationCourseTagAssignment = ZAutomationDraftTagAssignment.extend({
courseIds: z.array(z.string().uuid()).min(1).max(100)
});
export type TAutomationCourseTagAssignment = z.infer<typeof ZAutomationCourseTagAssignment>;