feat: implement automation feature
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import { and, desc, eq, isNull } from 'drizzle-orm';
|
||||
import { and, count, desc, eq, isNull } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@db/drizzle';
|
||||
import type { TNewOrganizationApiKey, TOrganizationApiKey, TOrganizationApiKeyType } from '@db/types';
|
||||
@@ -42,6 +42,29 @@ export const listOrganizationApiKeys = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const countActiveOrganizationApiKeys = async (
|
||||
organizationId: string,
|
||||
type: TOrganizationApiKeyType
|
||||
): Promise<number> => {
|
||||
try {
|
||||
const [row] = await db
|
||||
.select({ total: count() })
|
||||
.from(schema.organizationApiKey)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.organizationApiKey.organizationId, organizationId),
|
||||
eq(schema.organizationApiKey.type, type),
|
||||
isNull(schema.organizationApiKey.revokedAt)
|
||||
)
|
||||
);
|
||||
|
||||
return Number(row?.total ?? 0);
|
||||
} catch (error) {
|
||||
console.error('countActiveOrganizationApiKeys error:', error);
|
||||
throw new Error('Failed to count active organization API keys');
|
||||
}
|
||||
};
|
||||
|
||||
export const getOrganizationApiKeyById = async (
|
||||
organizationId: string,
|
||||
keyId: string
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import { and, count, desc, eq, gte, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@db/drizzle';
|
||||
import type { TAutomationUsageCategory, TNewOrganizationAutomationUsage, TOrganizationApiKeyType } from '@db/types';
|
||||
|
||||
export const createOrganizationAutomationUsage = async (data: TNewOrganizationAutomationUsage) => {
|
||||
try {
|
||||
const [row] = await db.insert(schema.organizationAutomationUsage).values(data).returning();
|
||||
|
||||
if (!row) {
|
||||
throw new Error('Failed to create organization automation usage');
|
||||
}
|
||||
|
||||
return row;
|
||||
} catch (error) {
|
||||
console.error('createOrganizationAutomationUsage error:', error);
|
||||
throw new Error('Failed to create organization automation usage');
|
||||
}
|
||||
};
|
||||
|
||||
export const countOrganizationAutomationUsageSinceByKey = async (
|
||||
organizationApiKeyId: string,
|
||||
category: TAutomationUsageCategory,
|
||||
since: string
|
||||
): Promise<number> => {
|
||||
try {
|
||||
const [row] = await db
|
||||
.select({ total: count() })
|
||||
.from(schema.organizationAutomationUsage)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.organizationAutomationUsage.organizationApiKeyId, organizationApiKeyId),
|
||||
eq(schema.organizationAutomationUsage.category, category),
|
||||
gte(schema.organizationAutomationUsage.createdAt, since)
|
||||
)
|
||||
);
|
||||
|
||||
return Number(row?.total ?? 0);
|
||||
} catch (error) {
|
||||
console.error('countOrganizationAutomationUsageSinceByKey error:', error);
|
||||
throw new Error('Failed to count organization automation usage by key');
|
||||
}
|
||||
};
|
||||
|
||||
export const countOrganizationAutomationUsageSince = async (
|
||||
organizationId: string,
|
||||
type: TOrganizationApiKeyType,
|
||||
category: TAutomationUsageCategory,
|
||||
since: string
|
||||
): Promise<number> => {
|
||||
try {
|
||||
const [row] = await db
|
||||
.select({ total: count() })
|
||||
.from(schema.organizationAutomationUsage)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.organizationAutomationUsage.organizationId, organizationId),
|
||||
eq(schema.organizationAutomationUsage.type, type),
|
||||
eq(schema.organizationAutomationUsage.category, category),
|
||||
gte(schema.organizationAutomationUsage.createdAt, since)
|
||||
)
|
||||
);
|
||||
|
||||
return Number(row?.total ?? 0);
|
||||
} catch (error) {
|
||||
console.error('countOrganizationAutomationUsageSince error:', error);
|
||||
throw new Error('Failed to count organization automation usage');
|
||||
}
|
||||
};
|
||||
|
||||
export const getOrganizationAutomationCreditsUsedSince = async (
|
||||
organizationId: string,
|
||||
type: TOrganizationApiKeyType,
|
||||
since: string
|
||||
): Promise<number> => {
|
||||
try {
|
||||
const [row] = await db
|
||||
.select({
|
||||
total: sql<number>`COALESCE(SUM(${schema.organizationAutomationUsage.creditsConsumed}), 0)`
|
||||
})
|
||||
.from(schema.organizationAutomationUsage)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.organizationAutomationUsage.organizationId, organizationId),
|
||||
eq(schema.organizationAutomationUsage.type, type),
|
||||
gte(schema.organizationAutomationUsage.createdAt, since)
|
||||
)
|
||||
);
|
||||
|
||||
return Number(row?.total ?? 0);
|
||||
} catch (error) {
|
||||
console.error('getOrganizationAutomationCreditsUsedSince error:', error);
|
||||
throw new Error('Failed to get organization automation credits used');
|
||||
}
|
||||
};
|
||||
|
||||
export const listRecentOrganizationAutomationUsage = async (
|
||||
organizationId: string,
|
||||
type: TOrganizationApiKeyType,
|
||||
limit = 10
|
||||
) => {
|
||||
try {
|
||||
return await db
|
||||
.select({
|
||||
action: schema.organizationAutomationUsage.action,
|
||||
creditsConsumed: schema.organizationAutomationUsage.creditsConsumed,
|
||||
createdAt: schema.organizationAutomationUsage.createdAt
|
||||
})
|
||||
.from(schema.organizationAutomationUsage)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.organizationAutomationUsage.organizationId, organizationId),
|
||||
eq(schema.organizationAutomationUsage.type, type)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(schema.organizationAutomationUsage.createdAt))
|
||||
.limit(limit);
|
||||
} catch (error) {
|
||||
console.error('listRecentOrganizationAutomationUsage error:', error);
|
||||
throw new Error('Failed to list recent organization automation usage');
|
||||
}
|
||||
};
|
||||
@@ -2,3 +2,4 @@ export * from './organization';
|
||||
export * from './quiz';
|
||||
export * from './invite';
|
||||
export * from './automation-key';
|
||||
export * from './automation-usage';
|
||||
|
||||
@@ -677,3 +677,13 @@ export const getOrganizationPlanStatus = async (orgId: string) => {
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const getActiveOrganizationPlan = async (orgId: string) => {
|
||||
const [plan] = await db
|
||||
.select()
|
||||
.from(schema.organizationPlan)
|
||||
.where(and(eq(schema.organizationPlan.orgId, orgId), eq(schema.organizationPlan.isActive, true)))
|
||||
.limit(1);
|
||||
|
||||
return plan ?? null;
|
||||
};
|
||||
|
||||
@@ -25,9 +25,10 @@ import { sql } from 'drizzle-orm';
|
||||
export const courseType = pgEnum('COURSE_TYPE', ['SELF_PACED', 'LIVE_CLASS']);
|
||||
export const locale = pgEnum('LOCALE', ['en', 'hi', 'fr', 'pt', 'de', 'vi', 'ru', 'es', 'pl', 'da']);
|
||||
export const plan = pgEnum('PLAN', ['EARLY_ADOPTER', 'ENTERPRISE', 'BASIC']);
|
||||
export const courseImportSourceType = pgEnum('COURSE_IMPORT_SOURCE_TYPE', ['prompt', 'pdf']);
|
||||
export const courseImportSourceType = pgEnum('COURSE_IMPORT_SOURCE_TYPE', ['prompt', 'pdf', 'course']);
|
||||
export const courseImportDraftStatus = pgEnum('COURSE_IMPORT_DRAFT_STATUS', ['DRAFT', 'PUBLISHED', 'ARCHIVED']);
|
||||
export const organizationApiKeyType = pgEnum('ORGANIZATION_API_KEY_TYPE', ['mcp', 'api', 'zapier']);
|
||||
export const automationUsageCategory = pgEnum('AUTOMATION_USAGE_CATEGORY', ['read', 'write', 'publish']);
|
||||
export const courseInviteEventType = pgEnum('COURSE_INVITE_EVENT_TYPE', [
|
||||
'CREATED',
|
||||
'REVOKED',
|
||||
@@ -2035,6 +2036,35 @@ export const organizationApiKey = pgTable(
|
||||
]
|
||||
);
|
||||
|
||||
export const organizationAutomationUsage = pgTable(
|
||||
'organization_automation_usage',
|
||||
{
|
||||
id: uuid()
|
||||
.default(sql`gen_random_uuid()`)
|
||||
.primaryKey()
|
||||
.notNull(),
|
||||
organizationId: uuid('organization_id')
|
||||
.notNull()
|
||||
.references(() => organization.id),
|
||||
organizationApiKeyId: uuid('organization_api_key_id').references(() => organizationApiKey.id, {
|
||||
onDelete: 'set null'
|
||||
}),
|
||||
type: organizationApiKeyType('type').notNull(),
|
||||
action: varchar('action', { length: 120 }).notNull(),
|
||||
category: automationUsageCategory('category').notNull(),
|
||||
creditsConsumed: integer('credits_consumed').default(0).notNull(),
|
||||
metadata: jsonb('metadata').default({}).$type<Record<string, unknown>>().notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' })
|
||||
.default(sql`timezone('utc'::text, now())`)
|
||||
.notNull()
|
||||
},
|
||||
(table) => [
|
||||
index('idx_org_automation_usage_org_created_at').on(table.organizationId, table.createdAt),
|
||||
index('idx_org_automation_usage_key_created_at').on(table.organizationApiKeyId, table.createdAt),
|
||||
index('idx_org_automation_usage_type_created_at').on(table.type, table.createdAt)
|
||||
]
|
||||
);
|
||||
|
||||
export const courseImportDraft = pgTable(
|
||||
'course_import_draft',
|
||||
{
|
||||
|
||||
@@ -171,6 +171,8 @@ export type TNewOrganizationTokenAuth = typeof schema.organizationTokenAuth.$inf
|
||||
|
||||
export type TOrganizationApiKey = typeof schema.organizationApiKey.$inferSelect;
|
||||
export type TNewOrganizationApiKey = typeof schema.organizationApiKey.$inferInsert;
|
||||
export type TOrganizationAutomationUsage = typeof schema.organizationAutomationUsage.$inferSelect;
|
||||
export type TNewOrganizationAutomationUsage = typeof schema.organizationAutomationUsage.$inferInsert;
|
||||
|
||||
export type TCourseImportDraft = typeof schema.courseImportDraft.$inferSelect;
|
||||
export type TNewCourseImportDraft = typeof schema.courseImportDraft.$inferInsert;
|
||||
@@ -188,6 +190,7 @@ export type TOrganizationInviteEventType = (typeof schema.organizationInviteEven
|
||||
export type TSsoProvider = (typeof schema.organizationSsoProviderType.enumValues)[number];
|
||||
|
||||
export type TOrganizationApiKeyType = (typeof schema.organizationApiKeyType.enumValues)[number];
|
||||
export type TAutomationUsageCategory = (typeof schema.automationUsageCategory.enumValues)[number];
|
||||
|
||||
export type TCourseImportSourceType = (typeof schema.courseImportSourceType.enumValues)[number];
|
||||
|
||||
|
||||
+20
-2
@@ -43,6 +43,7 @@ Current tools:
|
||||
|
||||
- `list_org_courses`
|
||||
- `get_course_structure`
|
||||
- `reorder_course_content`
|
||||
- `update_course_landing_page`
|
||||
- `create_course_draft`
|
||||
- `create_course_draft_from_course`
|
||||
@@ -167,7 +168,9 @@ Drafts support:
|
||||
Lesson authoring rule:
|
||||
|
||||
- `lessonLanguages[].content` should contain only the lesson body HTML
|
||||
- do not include the lesson title or a top-level heading that repeats `lessons[].title`
|
||||
- do not include the lesson title
|
||||
- do not use `h1` or `h2` in lesson content
|
||||
- start headings at `h3`
|
||||
- ClassroomIO already renders the lesson title in the course UI
|
||||
|
||||
## After Publish
|
||||
@@ -323,7 +326,22 @@ What this publish does today:
|
||||
- upserts lesson language content
|
||||
- merges draft tags into the existing course
|
||||
|
||||
### Flow 6: Add exercises to a live course
|
||||
### Flow 6: Reorder live course content
|
||||
|
||||
User says:
|
||||
|
||||
```text
|
||||
Move the quiz before lesson 2 and put the recap lesson into the last section.
|
||||
```
|
||||
|
||||
Expected tool sequence:
|
||||
|
||||
1. Agent calls `reorder_course_content`.
|
||||
|
||||
Use this direct path when the live course only needs section ordering or lesson or exercise moves between sections.
|
||||
It avoids the full draft round-trip for simple structural changes.
|
||||
|
||||
### Flow 7: Add exercises to a live course
|
||||
|
||||
User says:
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
TCourseImportDraftPublishToCourse,
|
||||
TCourseImportDraftUpdate
|
||||
} from '@cio/utils/validation/course-import';
|
||||
import type { TCourseLandingPageUpdate } from '@cio/utils/validation/course';
|
||||
import type { TCourseContentReorder, TCourseLandingPageUpdate, TCourseUpdateParam } from '@cio/utils/validation/course';
|
||||
import type {
|
||||
TExerciseCreate,
|
||||
TExerciseFromTemplate,
|
||||
@@ -78,6 +78,13 @@ export class ClassroomIoApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async reorderCourseContent(courseId: TCourseUpdateParam['courseId'], payload: TCourseContentReorder) {
|
||||
return this.request(`/course/${courseId}/content/reorder`, {
|
||||
method: 'PUT',
|
||||
body: payload
|
||||
});
|
||||
}
|
||||
|
||||
async getCourseDraft(draftId: string) {
|
||||
return this.request(`/organization/course-import/drafts/${draftId}`, {
|
||||
method: 'GET'
|
||||
|
||||
@@ -20,7 +20,11 @@ import {
|
||||
ZCourseImportDraftPublishToCourse,
|
||||
ZCourseImportDraftUpdate
|
||||
} from '@cio/utils/validation/course-import';
|
||||
import { ZCourseLandingPageUpdate } from '@cio/utils/validation/course';
|
||||
import {
|
||||
ZCourseContentReorder,
|
||||
ZCourseContentReorderBase,
|
||||
ZCourseLandingPageUpdate
|
||||
} from '@cio/utils/validation/course';
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import type { ZodRawShapeCompat } from '@modelcontextprotocol/sdk/server/zod-compat.js';
|
||||
|
||||
@@ -61,6 +65,10 @@ export const ZUpdateCourseLandingPageToolInput = ZCourseLandingPageUpdate.extend
|
||||
courseId: ZCourseImportCourseParam.shape.courseId
|
||||
});
|
||||
|
||||
export const ZReorderCourseContentToolInput = ZCourseContentReorderBase.extend({
|
||||
courseId: ZCourseImportCourseParam.shape.courseId
|
||||
});
|
||||
|
||||
export const ZPublishCourseDraftToolInput = ZCourseImportDraftPublish.extend({
|
||||
draftId: ZCourseImportDraftGetParam.shape.draftId
|
||||
});
|
||||
@@ -83,6 +91,7 @@ const createCourseExerciseFromTemplateShape =
|
||||
ZCreateCourseExerciseFromTemplateToolInput.shape as unknown as ZodRawShapeCompat;
|
||||
const updateCourseExerciseShape = ZUpdateCourseExerciseToolInput.shape as unknown as ZodRawShapeCompat;
|
||||
const updateCourseLandingPageShape = ZUpdateCourseLandingPageToolInput.shape as unknown as ZodRawShapeCompat;
|
||||
const reorderCourseContentShape = ZReorderCourseContentToolInput.shape as unknown as ZodRawShapeCompat;
|
||||
const publishCourseDraftShape = ZPublishCourseDraftToolInput.shape as unknown as ZodRawShapeCompat;
|
||||
const publishCourseDraftToExistingCourseShape =
|
||||
ZPublishCourseDraftToExistingCourseToolInput.shape as unknown as ZodRawShapeCompat;
|
||||
@@ -91,7 +100,7 @@ const tagCoursesShape = ZAutomationCourseTagAssignment.shape as unknown as ZodRa
|
||||
const SUPPORTED_QUESTION_TYPES_GUIDE =
|
||||
'Supported questionTypeId values: RADIO=1 (Single answer), CHECKBOX=2 (Multiple answers), TEXTAREA=3 (Paragraph), TRUE_FALSE=4 (True/False), SHORT_ANSWER=5 (Short answer), NUMERIC=6 (Numeric answer), FILL_BLANK=7 (Fill in the blank), FILE_UPLOAD=8 (File upload), MATCHING=9 (Matching), ORDERING=10 (Ordering), HOTSPOT=11 (Hotspot), LINK=12 (Links). Use the numeric ids in exercise payloads.';
|
||||
const LESSON_HTML_GUIDE =
|
||||
'For draft lesson HTML, put only the lesson body in lessonLanguages[].content. Do not include the lesson title or a top-level heading that repeats lessons[].title because the ClassroomIO UI already renders the lesson title.';
|
||||
'For draft lesson HTML, put only the lesson body in lessonLanguages[].content. Do not include the lesson title. Do not use h1 or h2 anywhere in lesson HTML. Start headings at h3 because that is the highest heading level allowed in lesson content.';
|
||||
|
||||
export function registerCourseDraftTools(server: McpServer, apiClient: ClassroomIoApiClient) {
|
||||
server.tool(
|
||||
@@ -127,6 +136,18 @@ export function registerCourseDraftTools(server: McpServer, apiClient: Classroom
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'reorder_course_content',
|
||||
'Batch reorder live course sections and lesson or exercise content without creating a draft. Use this for section ordering or moving/reordering lessons and exercises between sections. For items, pass type as LESSON or EXERCISE and use sectionId to move between sections or null for ungrouped content.',
|
||||
reorderCourseContentShape,
|
||||
async (args) => {
|
||||
const { courseId, ...rawPayload } = ZReorderCourseContentToolInput.parse(args);
|
||||
const payload = ZCourseContentReorder.parse(rawPayload);
|
||||
const result = await apiClient.reorderCourseContent(courseId, payload);
|
||||
return jsonContent(result);
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'create_course_draft',
|
||||
`Create a new unpublished course draft from structured course JSON. Use this before the first publish. ${LESSON_HTML_GUIDE}`,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import { sanitizeHtml } from '../../../../tools/sanitize';
|
||||
import { SafeHtmlContent } from '../../../safe-html-content';
|
||||
|
||||
import { getAnswerForQuestion, getSubmissionLabel } from '../submission-utils';
|
||||
|
||||
@@ -52,7 +53,7 @@
|
||||
{#each responseRows as row, index (`${index}-${row.responses}`)}
|
||||
<div class="ui:flex ui:items-start ui:justify-between ui:gap-3 ui:rounded-md ui:border ui:px-3 ui:py-2">
|
||||
<div class="ui:min-w-0 ui:flex-1 ui:break-words ui:text-sm">
|
||||
{@html row.html}
|
||||
<SafeHtmlContent content={row.html} />
|
||||
</div>
|
||||
<span class="ui:text-muted-foreground ui:mt-0.5 ui:shrink-0 ui:text-xs">{row.responses}</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as SafeHtmlContent } from './safe-html-content.svelte';
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import { splitHtmlAndSvg, type ContentSegment } from '../../tools/sanitize';
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
}
|
||||
|
||||
let { content }: Props = $props();
|
||||
|
||||
const segments: ContentSegment[] = $derived(splitHtmlAndSvg(content));
|
||||
|
||||
function svgDimensions(svg: string): { width: string; height: string } {
|
||||
const widthMatch = svg.match(/\bwidth\s*=\s*["'](\d+)/i);
|
||||
const heightMatch = svg.match(/\bheight\s*=\s*["'](\d+)/i);
|
||||
return {
|
||||
width: widthMatch ? `${widthMatch[1]}px` : '100%',
|
||||
height: heightMatch ? `${heightMatch[1]}px` : '150px'
|
||||
};
|
||||
}
|
||||
|
||||
function svgSrcdoc(rawSvg: string): string {
|
||||
return `<!DOCTYPE html><html><head><style>body{margin:0;display:flex;justify-content:center}
|
||||
</style></head><body>${rawSvg}</body></html>`;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each segments as segment}
|
||||
{#if segment.type === 'html'}
|
||||
{@html segment.content}
|
||||
{:else}
|
||||
{@const dims = svgDimensions(segment.content)}
|
||||
<iframe
|
||||
sandbox=""
|
||||
srcdoc={svgSrcdoc(segment.content)}
|
||||
title="Embedded diagram"
|
||||
style="border:none;overflow:hidden;width:{dims.width};height:{dims.height}"
|
||||
></iframe>
|
||||
{/if}
|
||||
File diff suppressed because one or more lines are too long
@@ -150,6 +150,37 @@ function fallbackSanitize(html: string): string {
|
||||
return html;
|
||||
}
|
||||
|
||||
export type ContentSegment = { type: 'html'; content: string } | { type: 'svg'; content: string };
|
||||
|
||||
/**
|
||||
* Splits HTML into safe-HTML segments and raw SVG segments.
|
||||
* HTML segments are sanitized; SVG segments are left raw so the caller
|
||||
* can render them inside a sandboxed iframe (sandbox="") where scripts,
|
||||
* forms, and cookie access are all disabled by the browser.
|
||||
*/
|
||||
export function splitHtmlAndSvg(html: string): ContentSegment[] {
|
||||
if (typeof html !== 'string' || !html) return [];
|
||||
|
||||
const segments: ContentSegment[] = [];
|
||||
const svgRegex = /<svg\b[^>]*>[\s\S]*?<\/svg>/gi;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = svgRegex.exec(html)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
segments.push({ type: 'html', content: sanitizeHtml(html.slice(lastIndex, match.index)) });
|
||||
}
|
||||
segments.push({ type: 'svg', content: match[0] });
|
||||
lastIndex = svgRegex.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < html.length) {
|
||||
segments.push({ type: 'html', content: sanitizeHtml(html.slice(lastIndex)) });
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips all HTML tags and returns plain text
|
||||
* Use this when you only need the text content
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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,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>;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user