feat: implement automation key management with middleware and routes for organization API keys, invite improvements
This commit is contained in:
@@ -2,6 +2,12 @@ name: Publish @classroomio/mcp to NPM
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches:
|
||||
- feat/release-v2
|
||||
paths:
|
||||
- ".github/workflows/publish-mcp.yml"
|
||||
- "packages/mcp/**"
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
@@ -15,7 +21,7 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.19.3
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
@@ -26,6 +32,7 @@ jobs:
|
||||
run: pnpm --filter @classroomio/mcp build
|
||||
|
||||
- name: Publish package
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: pnpm --filter @classroomio/mcp publish --no-git-checks --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
@@ -15,6 +15,7 @@ Use this file to keep a single prioritized view of all PRDs.
|
||||
| 7 | 13 | `prd/self-host-single-org.md` | done |
|
||||
| 8 | 7 | `prd/email-template-customization` | todo |
|
||||
| 9 | - | `prd/selective-exercise-assignment` | todo |
|
||||
| - | - | `prd/mcp-course-authoring` | todo |
|
||||
| - | 1 | `prd/ai-course-assistant` | todo |
|
||||
| - | 2 | `prd/compliance-training-platform` | todo |
|
||||
| - | 4 | `prd/course-content-search` | todo |
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Context, Next } from 'hono';
|
||||
|
||||
import { authMiddleware } from './auth';
|
||||
import { automationKeyMiddleware } from './automation-key';
|
||||
|
||||
export const authOrAutomationKeyMiddleware = async (c: Context, next: Next) => {
|
||||
const user = c.get('user');
|
||||
const session = c.get('session');
|
||||
|
||||
if (user && session) {
|
||||
c.set('automationKey', null);
|
||||
await authMiddleware(c, next);
|
||||
return;
|
||||
}
|
||||
|
||||
const authHeader = c.req.header('Authorization');
|
||||
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
return automationKeyMiddleware(c, next);
|
||||
}
|
||||
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Unauthorized: Authentication required (session or automation key)'
|
||||
},
|
||||
401
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Context, Next } from 'hono';
|
||||
|
||||
import {
|
||||
authenticateOrganizationApiKeyService,
|
||||
touchOrganizationApiKeyLastUsedService
|
||||
} from '@api/services/organization/automation-key';
|
||||
|
||||
export const automationKeyMiddleware = async (c: Context, next: Next) => {
|
||||
try {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
code: 'UNAUTHORIZED'
|
||||
},
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
const apiKey = authHeader.replace('Bearer ', '').trim();
|
||||
const automationKey = await authenticateOrganizationApiKeyService(apiKey);
|
||||
|
||||
c.set('automationKey', automationKey);
|
||||
c.set('orgId', automationKey.organizationId);
|
||||
c.set('actorId', automationKey.createdByProfileId);
|
||||
|
||||
await touchOrganizationApiKeyLastUsedService(automationKey);
|
||||
await next();
|
||||
} catch (error) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unauthorized',
|
||||
code: 'UNAUTHORIZED'
|
||||
},
|
||||
401
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Context, Next } from 'hono';
|
||||
|
||||
import { ErrorCodes } from '@api/utils/errors';
|
||||
import { env } from '@api/config/env';
|
||||
import { isUserOrgAdmin } from '@cio/db/queries/organization';
|
||||
|
||||
export const orgAdminOrApiKeyMiddleware = async (c: Context, next: Next) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id');
|
||||
|
||||
if (!orgId) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Organization ID is required',
|
||||
code: 'ORG_ID_REQUIRED'
|
||||
},
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const user = c.get('user');
|
||||
const authHeader = c.req.header('Authorization');
|
||||
const apiKey = authHeader?.startsWith('Bearer ') ? authHeader.replace('Bearer ', '').trim() : null;
|
||||
const isApiKeyRequest = Boolean(apiKey && env.PRIVATE_SERVER_KEY && apiKey === env.PRIVATE_SERVER_KEY);
|
||||
const actorId = user?.id ?? c.req.header('x-cio-actor-id');
|
||||
|
||||
if (!actorId) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Actor ID is required',
|
||||
code: 'ACTOR_ID_REQUIRED'
|
||||
},
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const isAdmin = await isUserOrgAdmin(orgId, actorId);
|
||||
if (!isAdmin) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Only organization admins can perform this action',
|
||||
code: ErrorCodes.ORG_TEAM_NOT_AUTHORIZED
|
||||
},
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
if (!user && !isApiKeyRequest) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
code: ErrorCodes.UNAUTHORIZED
|
||||
},
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
c.set('actorId', actorId);
|
||||
await next();
|
||||
} catch (error) {
|
||||
console.error('Error in orgAdminOrApiKeyMiddleware:', error);
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to verify organization admin status',
|
||||
code: 'ORG_ADMIN_CHECK_FAILED'
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Context, Next } from 'hono';
|
||||
import type { TOrganizationApiKeyScope } from '@cio/utils/validation/organization';
|
||||
|
||||
import { ErrorCodes } from '@api/utils/errors';
|
||||
import { isUserOrgAdmin } from '@cio/db/queries/organization';
|
||||
import { organizationApiKeyHasScopes } from '@api/services/organization/automation-key';
|
||||
|
||||
export const orgAdminOrAutomationKeyMiddleware =
|
||||
(requiredScopes: readonly TOrganizationApiKeyScope[] = []) =>
|
||||
async (c: Context, next: Next) => {
|
||||
try {
|
||||
const automationKey = c.get('automationKey');
|
||||
|
||||
if (automationKey) {
|
||||
if (!organizationApiKeyHasScopes(automationKey.scopes ?? [], requiredScopes)) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Automation key is missing required scopes',
|
||||
code: ErrorCodes.FORBIDDEN
|
||||
},
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
c.set('orgId', automationKey.organizationId);
|
||||
c.set('actorId', automationKey.createdByProfileId);
|
||||
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
code: ErrorCodes.UNAUTHORIZED
|
||||
},
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
const orgId = c.req.header('cio-org-id');
|
||||
|
||||
if (!orgId) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Organization ID is required',
|
||||
code: 'ORG_ID_REQUIRED'
|
||||
},
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const isAdmin = await isUserOrgAdmin(orgId, user.id);
|
||||
if (!isAdmin) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Only organization admins can perform this action',
|
||||
code: ErrorCodes.ORG_TEAM_NOT_AUTHORIZED
|
||||
},
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
c.set('orgId', orgId);
|
||||
c.set('actorId', user.id);
|
||||
|
||||
await next();
|
||||
} catch (error) {
|
||||
console.error('Error in orgAdminOrAutomationKeyMiddleware:', error);
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to verify organization admin status',
|
||||
code: 'ORG_ADMIN_CHECK_FAILED'
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
ZCreateOrganizationApiKey,
|
||||
ZListOrganizationApiKeysQuery,
|
||||
ZOrganizationApiKeyParam
|
||||
} from '@cio/utils/validation/organization';
|
||||
import {
|
||||
createOrganizationApiKeyService,
|
||||
listOrganizationApiKeysService,
|
||||
revokeOrganizationApiKeyService,
|
||||
rotateOrganizationApiKeyService
|
||||
} from '@api/services/organization/automation-key';
|
||||
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { authMiddleware } from '@api/middlewares/auth';
|
||||
import { handleError } from '@api/utils/errors';
|
||||
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
|
||||
export const automationRouter = new Hono()
|
||||
.get('/keys', authMiddleware, orgAdminMiddleware, zValidator('query', ZListOrganizationApiKeysQuery), async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const query = c.req.valid('query');
|
||||
const keys = await listOrganizationApiKeysService(orgId, query);
|
||||
|
||||
return c.json({ success: true, data: keys }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to list organization automation keys');
|
||||
}
|
||||
})
|
||||
.post('/keys', authMiddleware, orgAdminMiddleware, zValidator('json', ZCreateOrganizationApiKey), async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const user = c.get('user')!;
|
||||
const payload = c.req.valid('json');
|
||||
const key = await createOrganizationApiKeyService(orgId, user.id, payload);
|
||||
|
||||
return c.json({ success: true, data: key }, 201);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to create organization automation key');
|
||||
}
|
||||
})
|
||||
.delete(
|
||||
'/keys/:keyId',
|
||||
authMiddleware,
|
||||
orgAdminMiddleware,
|
||||
zValidator('param', ZOrganizationApiKeyParam),
|
||||
async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const { keyId } = c.req.valid('param');
|
||||
const key = await revokeOrganizationApiKeyService(orgId, keyId);
|
||||
|
||||
return c.json({ success: true, data: key }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to revoke organization automation key');
|
||||
}
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/keys/:keyId/rotate',
|
||||
authMiddleware,
|
||||
orgAdminMiddleware,
|
||||
zValidator('param', ZOrganizationApiKeyParam),
|
||||
async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const { keyId } = c.req.valid('param');
|
||||
const key = await rotateOrganizationApiKeyService(orgId, keyId);
|
||||
|
||||
return c.json({ success: true, data: key }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to rotate organization automation key');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
ZCourseImportDraftCreate,
|
||||
ZCourseImportDraftGetParam,
|
||||
ZCourseImportDraftPublish,
|
||||
ZCourseImportDraftUpdate
|
||||
} from '@cio/utils/validation/course-import';
|
||||
import {
|
||||
createCourseImportDraftService,
|
||||
getCourseImportDraftService,
|
||||
publishCourseImportDraftService,
|
||||
updateCourseImportDraftService
|
||||
} from '@api/services/course-import/course-import';
|
||||
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
|
||||
import { handleError } from '@api/utils/errors';
|
||||
import { orgAdminOrAutomationKeyMiddleware } from '@api/middlewares/org-admin-or-automation-key';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
|
||||
export const courseImportRouter = new Hono()
|
||||
.post(
|
||||
'/drafts',
|
||||
authOrAutomationKeyMiddleware,
|
||||
orgAdminOrAutomationKeyMiddleware(['course_import:draft:create']),
|
||||
zValidator('json', ZCourseImportDraftCreate),
|
||||
async (c) => {
|
||||
try {
|
||||
const orgId = c.get('orgId')!;
|
||||
const actorId = c.get('actorId')!;
|
||||
const payload = c.req.valid('json');
|
||||
|
||||
const draft = await createCourseImportDraftService(orgId, actorId, payload);
|
||||
|
||||
return c.json({ success: true, data: draft }, 201);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to create course import draft');
|
||||
}
|
||||
}
|
||||
)
|
||||
.get(
|
||||
'/drafts/:draftId',
|
||||
authOrAutomationKeyMiddleware,
|
||||
orgAdminOrAutomationKeyMiddleware(['course_import:draft:read']),
|
||||
zValidator('param', ZCourseImportDraftGetParam),
|
||||
async (c) => {
|
||||
try {
|
||||
const orgId = c.get('orgId')!;
|
||||
const { draftId } = c.req.valid('param');
|
||||
|
||||
const draft = await getCourseImportDraftService(orgId, draftId);
|
||||
|
||||
return c.json({ success: true, data: draft }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to fetch course import draft');
|
||||
}
|
||||
}
|
||||
)
|
||||
.put(
|
||||
'/drafts/:draftId',
|
||||
authOrAutomationKeyMiddleware,
|
||||
orgAdminOrAutomationKeyMiddleware(['course_import:draft:update']),
|
||||
zValidator('param', ZCourseImportDraftGetParam),
|
||||
zValidator('json', ZCourseImportDraftUpdate),
|
||||
async (c) => {
|
||||
try {
|
||||
const orgId = c.get('orgId')!;
|
||||
const { draftId } = c.req.valid('param');
|
||||
const payload = c.req.valid('json');
|
||||
|
||||
const draft = await updateCourseImportDraftService(orgId, draftId, payload);
|
||||
|
||||
return c.json({ success: true, data: draft }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to update course import draft');
|
||||
}
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/drafts/:draftId/publish',
|
||||
authOrAutomationKeyMiddleware,
|
||||
orgAdminOrAutomationKeyMiddleware(['course_import:draft:publish']),
|
||||
zValidator('param', ZCourseImportDraftGetParam),
|
||||
zValidator('json', ZCourseImportDraftPublish),
|
||||
async (c) => {
|
||||
try {
|
||||
const orgId = c.get('orgId')!;
|
||||
const actorId = c.get('actorId')!;
|
||||
const { draftId } = c.req.valid('param');
|
||||
const payload = c.req.valid('json');
|
||||
|
||||
const result = await publishCourseImportDraftService(orgId, actorId, draftId, payload);
|
||||
|
||||
return c.json({ success: true, data: result }, 201);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to publish course import draft');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -2,3 +2,5 @@ export * from './organization';
|
||||
export * from './assets';
|
||||
export * from './tags';
|
||||
export * from './token-auth';
|
||||
export * from './course-import';
|
||||
export * from './automation';
|
||||
|
||||
@@ -34,9 +34,11 @@ import {
|
||||
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { TOrganization } from '@db/types';
|
||||
import { automationRouter } from '@api/routes/organization/automation';
|
||||
import { assetsRouter } from '@api/routes/organization/assets';
|
||||
import { authMiddleware } from '@api/middlewares/auth';
|
||||
import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key';
|
||||
import { courseImportRouter } from '@api/routes/organization/course-import';
|
||||
import { getLMSExercisesService } from '@api/services/exercise';
|
||||
import { handleError } from '@api/utils/errors';
|
||||
import { inviteTeamMembers } from '@api/services/organization/invite';
|
||||
@@ -505,6 +507,8 @@ export const organizationRouter = new Hono()
|
||||
return handleError(c, error, 'Failed to fetch LMS exercises');
|
||||
}
|
||||
})
|
||||
.route('/automation', automationRouter)
|
||||
.route('/course-import', courseImportRouter)
|
||||
.route('/tags', tagsRouter)
|
||||
.route('/assets', assetsRouter)
|
||||
.route('/:orgId/quiz', quizRouter);
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import {
|
||||
createCourseImportDraft,
|
||||
getCourseImportDraftById,
|
||||
getCourseImportDraftByIdempotencyKey,
|
||||
updateCourseImportDraft
|
||||
} from '@cio/db/queries/course-import';
|
||||
import type { TCourseImportDraft, TLocale } from '@db/types';
|
||||
import type {
|
||||
TCourseImportDraftCreate,
|
||||
TCourseImportDraftPayload,
|
||||
TCourseImportDraftPublish,
|
||||
TCourseImportDraftUpdate
|
||||
} from '@cio/utils/validation/course-import';
|
||||
import { ZCourseImportDraftPayload } from '@cio/utils/validation/course-import';
|
||||
import { createCourse, updateCourse } from '@api/services/course/course';
|
||||
import { createCourseSection } from '@api/services/course/section';
|
||||
import { createLesson } from '@api/services/lesson';
|
||||
import { upsertLessonLanguageService } from '@api/services/lesson-language';
|
||||
|
||||
type DraftSummary = {
|
||||
sectionCount: number;
|
||||
lessonCount: number;
|
||||
localeCount: number;
|
||||
warningCount: number;
|
||||
};
|
||||
|
||||
function buildDraftSummary(draft: TCourseImportDraftPayload): DraftSummary {
|
||||
return {
|
||||
sectionCount: draft.sections.length,
|
||||
lessonCount: draft.lessons.length,
|
||||
localeCount: new Set(draft.lessonLanguages.map((item) => item.locale)).size,
|
||||
warningCount: draft.warnings.length
|
||||
};
|
||||
}
|
||||
|
||||
function parseStoredDraft(record: TCourseImportDraft): TCourseImportDraftPayload {
|
||||
return ZCourseImportDraftPayload.parse(record.draft);
|
||||
}
|
||||
|
||||
export async function createCourseImportDraftService(orgId: string, profileId: string, data: TCourseImportDraftCreate) {
|
||||
try {
|
||||
if (data.idempotencyKey) {
|
||||
const existing = await getCourseImportDraftByIdempotencyKey(orgId, data.idempotencyKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
return await createCourseImportDraft({
|
||||
organizationId: orgId,
|
||||
createdByProfileId: profileId,
|
||||
sourceType: data.sourceType,
|
||||
status: 'DRAFT',
|
||||
title: data.draft.course.title,
|
||||
locale: data.draft.course.locale,
|
||||
idempotencyKey: data.idempotencyKey,
|
||||
summary: {
|
||||
...buildDraftSummary(data.draft),
|
||||
...(data.summary ?? {})
|
||||
},
|
||||
draft: data.draft,
|
||||
warnings: data.draft.warnings,
|
||||
sourceArtifacts: data.sourceArtifacts ?? data.draft.sourceReferences ?? []
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to create course import draft',
|
||||
ErrorCodes.COURSE_IMPORT_CREATE_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCourseImportDraftService(orgId: string, draftId: string) {
|
||||
try {
|
||||
const draft = await getCourseImportDraftById(orgId, draftId);
|
||||
if (!draft) {
|
||||
throw new AppError('Course import draft not found', ErrorCodes.COURSE_IMPORT_DRAFT_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return draft;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to fetch course import draft',
|
||||
ErrorCodes.COURSE_IMPORT_FETCH_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCourseImportDraftService(orgId: string, draftId: string, data: TCourseImportDraftUpdate) {
|
||||
try {
|
||||
const existing = await getCourseImportDraftService(orgId, draftId);
|
||||
|
||||
if (existing.status === 'PUBLISHED') {
|
||||
throw new AppError('Published drafts cannot be edited', ErrorCodes.FORBIDDEN, 403);
|
||||
}
|
||||
|
||||
const nextDraft = data.draft ?? parseStoredDraft(existing);
|
||||
const nextWarnings = data.warnings ?? nextDraft.warnings;
|
||||
const updated = await updateCourseImportDraft(orgId, draftId, {
|
||||
title: nextDraft.course.title,
|
||||
locale: nextDraft.course.locale,
|
||||
draft: nextDraft,
|
||||
warnings: nextWarnings,
|
||||
summary: {
|
||||
...(existing.summary ?? {}),
|
||||
...buildDraftSummary(nextDraft),
|
||||
...(data.summary ?? {})
|
||||
},
|
||||
sourceArtifacts: data.sourceArtifacts ?? existing.sourceArtifacts
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
throw new AppError('Course import draft not found', ErrorCodes.COURSE_IMPORT_DRAFT_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return updated;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to update course import draft',
|
||||
ErrorCodes.COURSE_IMPORT_UPDATE_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function publishCourseImportDraftService(
|
||||
orgId: string,
|
||||
profileId: string,
|
||||
draftId: string,
|
||||
overrides: TCourseImportDraftPublish
|
||||
) {
|
||||
try {
|
||||
const existing = await getCourseImportDraftService(orgId, draftId);
|
||||
|
||||
if (existing.status === 'PUBLISHED' && existing.publishedCourseId) {
|
||||
return {
|
||||
courseId: existing.publishedCourseId,
|
||||
createdSections: 0,
|
||||
createdLessons: 0,
|
||||
localeCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
const draft = parseStoredDraft(existing);
|
||||
const courseTitle = overrides.title ?? draft.course.title;
|
||||
const courseDescription = overrides.description ?? draft.course.description;
|
||||
const courseType = overrides.type ?? draft.course.type;
|
||||
const courseMetadata = overrides.metadata ?? draft.course.metadata;
|
||||
|
||||
const sectionExternalIds = new Set(draft.sections.map((section) => section.externalId));
|
||||
draft.lessons.forEach((lesson) => {
|
||||
if (!sectionExternalIds.has(lesson.sectionExternalId)) {
|
||||
throw new AppError(`Lesson "${lesson.title}" references an unknown section`, ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
});
|
||||
|
||||
const courseResult = await createCourse(profileId, {
|
||||
title: courseTitle,
|
||||
description: courseDescription,
|
||||
type: courseType,
|
||||
organizationId: orgId
|
||||
});
|
||||
|
||||
const courseId = courseResult.course.id;
|
||||
|
||||
if (courseMetadata) {
|
||||
await updateCourse(courseId, { metadata: courseMetadata });
|
||||
}
|
||||
|
||||
const sectionIdMap = new Map<string, string>();
|
||||
for (const section of [...draft.sections].sort((a, b) => a.order - b.order)) {
|
||||
const createdSection = await createCourseSection(courseId, {
|
||||
courseId,
|
||||
title: section.title,
|
||||
order: section.order
|
||||
});
|
||||
|
||||
sectionIdMap.set(section.externalId, createdSection.id);
|
||||
}
|
||||
|
||||
const lessonIdMap = new Map<string, string>();
|
||||
for (const lesson of [...draft.lessons].sort((a, b) => a.order - b.order)) {
|
||||
const sectionId = sectionIdMap.get(lesson.sectionExternalId);
|
||||
if (!sectionId) {
|
||||
throw new AppError('Referenced section was not created', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
const createdLesson = await createLesson(courseId, {
|
||||
courseId,
|
||||
title: lesson.title,
|
||||
sectionId,
|
||||
order: lesson.order,
|
||||
isUnlocked: lesson.isUnlocked,
|
||||
public: lesson.public
|
||||
});
|
||||
|
||||
lessonIdMap.set(lesson.externalId, createdLesson.id);
|
||||
}
|
||||
|
||||
for (const lessonLanguage of draft.lessonLanguages) {
|
||||
const lessonId = lessonIdMap.get(lessonLanguage.lessonExternalId);
|
||||
if (!lessonId) {
|
||||
throw new AppError('Referenced lesson was not created', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
await upsertLessonLanguageService(lessonId, {
|
||||
locale: lessonLanguage.locale as TLocale,
|
||||
content: lessonLanguage.content
|
||||
});
|
||||
}
|
||||
|
||||
await updateCourseImportDraft(orgId, draftId, {
|
||||
status: 'PUBLISHED',
|
||||
publishedCourseId: courseId,
|
||||
summary: {
|
||||
...(existing.summary ?? {}),
|
||||
...buildDraftSummary(draft),
|
||||
publishedCourseId: courseId
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
courseId,
|
||||
createdSections: sectionIdMap.size,
|
||||
createdLessons: lessonIdMap.size,
|
||||
localeCount: new Set(draft.lessonLanguages.map((item) => item.locale)).size
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to publish course import draft',
|
||||
ErrorCodes.COURSE_IMPORT_PUBLISH_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
||||
import { getCourseTeachers } from '@cio/db/queries/course/people';
|
||||
import { getProfileById } from '@cio/db/queries/auth';
|
||||
import { buildEmailFromName, sendEmail } from '@cio/email';
|
||||
import { getProfileByEmail } from '@cio/db/queries/auth';
|
||||
import { generateSlug } from '@cio/utils/functions';
|
||||
|
||||
type InviteStatus = 'ACTIVE' | 'EXPIRED' | 'USED_UP' | 'REVOKED';
|
||||
type InvitePreset = 'ONE_TIME_24H' | 'MULTI_USE_7D' | 'MULTI_USE_30D' | 'CUSTOM';
|
||||
@@ -38,6 +40,39 @@ const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const ANOMALY_WINDOW_MINUTES = 60;
|
||||
const MAX_PREVIEW_IP_DIVERSITY = 25;
|
||||
|
||||
async function generateUniqueCourseSlug(baseSlug: string): Promise<string> {
|
||||
let suffix = 0;
|
||||
let candidate = baseSlug;
|
||||
|
||||
while (true) {
|
||||
const existing = await db
|
||||
.select({ id: schema.course.id })
|
||||
.from(schema.course)
|
||||
.where(eq(schema.course.slug, candidate))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
suffix += 1;
|
||||
candidate = `${baseSlug}-${suffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCourseSlug(courseId: string, title: string | null | undefined): Promise<string> {
|
||||
const baseSlug = generateSlug(title, { fallback: 'course' });
|
||||
const uniqueSlug = await generateUniqueCourseSlug(baseSlug);
|
||||
|
||||
const [updated] = await db
|
||||
.update(schema.course)
|
||||
.set({ slug: uniqueSlug, updatedAt: new Date().toISOString() })
|
||||
.where(eq(schema.course.id, courseId))
|
||||
.returning({ slug: schema.course.slug });
|
||||
|
||||
return updated?.slug ?? uniqueSlug;
|
||||
}
|
||||
|
||||
interface TInviteRequestContext {
|
||||
ipAddress?: string | null;
|
||||
userAgent?: string | null;
|
||||
@@ -481,11 +516,9 @@ export async function createStudentInvite(courseId: string, createdByProfileId:
|
||||
);
|
||||
}
|
||||
|
||||
const courseSlug = course[0].slug;
|
||||
const courseTitle = course[0].title;
|
||||
const courseSlug = course[0].slug ?? (await ensureCourseSlug(course[0].id, courseTitle));
|
||||
const orgSiteName = courseOrgData.orgSiteName ?? null;
|
||||
if (!courseSlug) {
|
||||
throw new AppError('Course must have a slug to create invite links', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
if (recipients.valid.length === 0) {
|
||||
const createdInvite = await createSingleInvite(
|
||||
@@ -803,6 +836,17 @@ export async function previewStudentInvite(token: string, context: TInviteReques
|
||||
});
|
||||
|
||||
const status = getInviteStatus(data.invite);
|
||||
const recipientEmail = data.invite.allowedEmails?.[0]?.toLowerCase().trim() || null;
|
||||
let recipientExists = false;
|
||||
|
||||
if (recipientEmail) {
|
||||
try {
|
||||
const profile = await getProfileByEmail(recipientEmail);
|
||||
recipientExists = !!profile;
|
||||
} catch (error) {
|
||||
console.error('previewStudentInvite getProfileByEmail error:', error);
|
||||
}
|
||||
}
|
||||
const courseMetadata = data.course.metadata as { allowNewStudent?: boolean } | null;
|
||||
|
||||
return {
|
||||
@@ -828,6 +872,10 @@ export async function previewStudentInvite(token: string, context: TInviteReques
|
||||
status: data.course.status,
|
||||
slug: data.course.slug ?? undefined,
|
||||
cost: data.course.cost
|
||||
},
|
||||
inviteContext: {
|
||||
recipientEmail,
|
||||
recipientExists
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import type {
|
||||
TCreateOrganizationApiKey,
|
||||
TListOrganizationApiKeysQuery,
|
||||
TOrganizationApiKeyScope,
|
||||
TOrganizationApiKeyType
|
||||
} from '@cio/utils/validation/organization';
|
||||
import type { TOrganizationApiKey } from '@db/types';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import {
|
||||
createOrganizationApiKey,
|
||||
getActiveOrganizationApiKeyByHash,
|
||||
getOrganizationApiKeyById,
|
||||
listOrganizationApiKeys,
|
||||
updateOrganizationApiKey
|
||||
} from '@cio/db/queries/organization';
|
||||
|
||||
const DEFAULT_SCOPES: Record<TOrganizationApiKeyType, TOrganizationApiKeyScope[]> = {
|
||||
mcp: [
|
||||
'course_import:draft:create',
|
||||
'course_import:draft:read',
|
||||
'course_import:draft:update',
|
||||
'course_import:draft:publish'
|
||||
],
|
||||
api: ['public_api:*'],
|
||||
zapier: ['public_api:*']
|
||||
};
|
||||
|
||||
function getDefaultLabel(type: TOrganizationApiKeyType) {
|
||||
switch (type) {
|
||||
case 'mcp':
|
||||
return 'MCP key';
|
||||
case 'api':
|
||||
return 'API key';
|
||||
case 'zapier':
|
||||
return 'Zapier key';
|
||||
}
|
||||
}
|
||||
|
||||
function generateOrganizationApiKeySecret(type: TOrganizationApiKeyType) {
|
||||
return `cio_${type}_${randomBytes(24).toString('base64url')}`;
|
||||
}
|
||||
|
||||
function hashSecret(secret: string) {
|
||||
return createHash('sha256').update(secret).digest('hex');
|
||||
}
|
||||
|
||||
function getSecretPrefix(secret: string) {
|
||||
return secret.slice(0, 16);
|
||||
}
|
||||
|
||||
function isExpired(expiresAt: string | null) {
|
||||
return Boolean(expiresAt && new Date(expiresAt).getTime() <= Date.now());
|
||||
}
|
||||
|
||||
export type OrganizationApiKeySummary = Omit<TOrganizationApiKey, 'secretHash'>;
|
||||
|
||||
function sanitizeOrganizationApiKey(key: TOrganizationApiKey): OrganizationApiKeySummary {
|
||||
const { secretHash: _secretHash, ...rest } = key;
|
||||
return rest;
|
||||
}
|
||||
|
||||
export function organizationApiKeyHasScopes(
|
||||
keyScopes: string[],
|
||||
requiredScopes: readonly TOrganizationApiKeyScope[]
|
||||
): boolean {
|
||||
if (requiredScopes.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const scopeSet = new Set(keyScopes);
|
||||
return requiredScopes.every((scope) => scopeSet.has(scope));
|
||||
}
|
||||
|
||||
export async function listOrganizationApiKeysService(
|
||||
organizationId: string,
|
||||
query: TListOrganizationApiKeysQuery
|
||||
): Promise<OrganizationApiKeySummary[]> {
|
||||
const keys = await listOrganizationApiKeys(organizationId, query.type);
|
||||
return keys.map(sanitizeOrganizationApiKey);
|
||||
}
|
||||
|
||||
export async function createOrganizationApiKeyService(
|
||||
organizationId: string,
|
||||
createdByProfileId: string,
|
||||
payload: TCreateOrganizationApiKey
|
||||
): Promise<{ key: OrganizationApiKeySummary; secret: string }> {
|
||||
const secret = generateOrganizationApiKeySecret(payload.type);
|
||||
const key = await createOrganizationApiKey({
|
||||
organizationId,
|
||||
createdByProfileId,
|
||||
type: payload.type,
|
||||
label: payload.label?.trim() || getDefaultLabel(payload.type),
|
||||
secretPrefix: getSecretPrefix(secret),
|
||||
secretHash: hashSecret(secret),
|
||||
scopes: payload.scopes ?? DEFAULT_SCOPES[payload.type],
|
||||
expiresAt: payload.expiresAt ?? null
|
||||
});
|
||||
|
||||
return {
|
||||
key: sanitizeOrganizationApiKey(key),
|
||||
secret
|
||||
};
|
||||
}
|
||||
|
||||
export async function revokeOrganizationApiKeyService(organizationId: string, keyId: string) {
|
||||
const existing = await getOrganizationApiKeyById(organizationId, keyId);
|
||||
if (!existing) {
|
||||
throw new AppError('Organization API key not found', ErrorCodes.ORGANIZATION_API_KEY_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const updated = await updateOrganizationApiKey(organizationId, keyId, {
|
||||
revokedAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
throw new AppError('Failed to revoke organization API key', ErrorCodes.ORGANIZATION_API_KEY_REVOKE_FAILED, 500);
|
||||
}
|
||||
|
||||
return sanitizeOrganizationApiKey(updated);
|
||||
}
|
||||
|
||||
export async function rotateOrganizationApiKeyService(organizationId: string, keyId: string) {
|
||||
const existing = await getOrganizationApiKeyById(organizationId, keyId);
|
||||
if (!existing) {
|
||||
throw new AppError('Organization API key not found', ErrorCodes.ORGANIZATION_API_KEY_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const secret = generateOrganizationApiKeySecret(existing.type);
|
||||
const updated = await updateOrganizationApiKey(organizationId, keyId, {
|
||||
secretPrefix: getSecretPrefix(secret),
|
||||
secretHash: hashSecret(secret),
|
||||
revokedAt: null,
|
||||
lastUsedAt: null
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
throw new AppError('Failed to rotate organization API key', ErrorCodes.ORGANIZATION_API_KEY_ROTATE_FAILED, 500);
|
||||
}
|
||||
|
||||
return {
|
||||
key: sanitizeOrganizationApiKey(updated),
|
||||
secret
|
||||
};
|
||||
}
|
||||
|
||||
export async function authenticateOrganizationApiKeyService(rawSecret: string): Promise<TOrganizationApiKey> {
|
||||
const key = await getActiveOrganizationApiKeyByHash(hashSecret(rawSecret));
|
||||
|
||||
if (!key) {
|
||||
throw new AppError('Invalid organization API key', ErrorCodes.UNAUTHORIZED, 401);
|
||||
}
|
||||
|
||||
if (isExpired(key.expiresAt ?? null)) {
|
||||
throw new AppError('Organization API key has expired', ErrorCodes.UNAUTHORIZED, 401);
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
export async function touchOrganizationApiKeyLastUsedService(key: TOrganizationApiKey): Promise<void> {
|
||||
await updateOrganizationApiKey(key.organizationId, key.id, {
|
||||
lastUsedAt: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { auth } from '@cio/db/auth';
|
||||
import type { TOrganizationApiKey } from '@db/types';
|
||||
|
||||
export type AuthSession = {
|
||||
Variables: {
|
||||
actorId: string | null;
|
||||
automationKey: TOrganizationApiKey | null;
|
||||
orgId: string | null;
|
||||
session: typeof auth.$Infer.Session.session | null;
|
||||
user: typeof auth.$Infer.Session.user | null;
|
||||
|
||||
@@ -62,6 +62,11 @@ export const ErrorCodes = {
|
||||
ORG_TEAM_REMOVE_FAILED: 'ORG_TEAM_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',
|
||||
TAG_GROUP_NOT_FOUND: 'TAG_GROUP_NOT_FOUND',
|
||||
TAG_NOT_FOUND: 'TAG_NOT_FOUND',
|
||||
TAG_FETCH_FAILED: 'TAG_FETCH_FAILED',
|
||||
@@ -97,6 +102,11 @@ export const ErrorCodes = {
|
||||
// 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',
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type {
|
||||
AutomationKey,
|
||||
AutomationKeyType,
|
||||
CreateAutomationKeyRequest,
|
||||
ListAutomationKeysRequest,
|
||||
RevokeAutomationKeyRequest,
|
||||
RotateAutomationKeyRequest
|
||||
} from '$features/automation/utils/types';
|
||||
import { getDefaultAutomationKeyLabel } from '$features/automation/utils/automation-utils';
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
|
||||
class AutomationApi extends BaseApiWithErrors {
|
||||
generatedSecret = $state<string | null>(null);
|
||||
isFetched = $state(false);
|
||||
keys = $state<AutomationKey[]>([]);
|
||||
|
||||
async listKeys(type?: AutomationKeyType) {
|
||||
return this.execute<ListAutomationKeysRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.organization.automation.keys.$get({
|
||||
query: type ? { type } : {}
|
||||
}),
|
||||
logContext: 'fetching automation keys',
|
||||
onSuccess: (response) => {
|
||||
this.keys = response.data;
|
||||
this.isFetched = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async createKey(type: AutomationKeyType, label?: string) {
|
||||
return this.execute<CreateAutomationKeyRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.organization.automation.keys.$post({
|
||||
json: {
|
||||
type,
|
||||
label: label?.trim() || getDefaultAutomationKeyLabel(type)
|
||||
}
|
||||
}),
|
||||
logContext: 'creating automation key',
|
||||
onSuccess: (response) => {
|
||||
this.generatedSecret = response.data.secret;
|
||||
this.keys = [response.data.key, ...this.keys];
|
||||
snackbar.success('snackbar.automation.created');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async revokeKey(keyId: string) {
|
||||
return this.execute<RevokeAutomationKeyRequest>({
|
||||
requestFn: () => classroomio.organization.automation.keys[':keyId'].$delete({ param: { keyId } }),
|
||||
logContext: 'revoking automation key',
|
||||
onSuccess: (response) => {
|
||||
this.keys = this.keys.map((key) => (key.id === response.data.id ? response.data : key));
|
||||
snackbar.success('snackbar.automation.revoked');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async rotateKey(keyId: string) {
|
||||
return this.execute<RotateAutomationKeyRequest>({
|
||||
requestFn: () => classroomio.organization.automation.keys[':keyId'].rotate.$post({ param: { keyId } }),
|
||||
logContext: 'rotating automation key',
|
||||
onSuccess: (response) => {
|
||||
this.generatedSecret = response.data.secret;
|
||||
this.keys = this.keys.map((key) => (key.id === response.data.key.id ? response.data.key : key));
|
||||
snackbar.success('snackbar.automation.rotated');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
clearGeneratedSecret() {
|
||||
this.generatedSecret = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const automationApi = new AutomationApi();
|
||||
@@ -0,0 +1,269 @@
|
||||
<script lang="ts">
|
||||
import { automationApi } from '$features/automation/api/automation.svelte';
|
||||
import {
|
||||
getClaudeCodeSnippet,
|
||||
getCodexSnippet,
|
||||
getCursorSnippet,
|
||||
getMaskedAutomationSecret
|
||||
} from '$features/automation/utils/automation-utils';
|
||||
import { isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import * as Alert from '@cio/ui/base/alert';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Card from '@cio/ui/base/card';
|
||||
import * as Code from '@cio/ui/custom/code';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import * as Tabs from '@cio/ui/base/tabs';
|
||||
import KeyIcon from '@lucide/svelte/icons/key';
|
||||
import SparklesIcon from '@lucide/svelte/icons/sparkles';
|
||||
import WandSparklesIcon from '@lucide/svelte/icons/wand-sparkles';
|
||||
import BracesIcon from '@lucide/svelte/icons/braces';
|
||||
|
||||
let activeTab = $state('mcp');
|
||||
let activeSetupTab = $state('claude-code');
|
||||
let isRevokingKeyId = $state<string | null>(null);
|
||||
let isRotatingKeyId = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (!$isOrgAdmin || automationApi.isFetched) return;
|
||||
|
||||
automationApi.listKeys('mcp');
|
||||
});
|
||||
|
||||
async function onGenerateKey() {
|
||||
await automationApi.createKey('mcp');
|
||||
}
|
||||
|
||||
async function onRevokeKey(keyId: string) {
|
||||
if (!confirm(t.get('automation.keys.revoke_confirm'))) return;
|
||||
|
||||
isRevokingKeyId = keyId;
|
||||
await automationApi.revokeKey(keyId);
|
||||
isRevokingKeyId = null;
|
||||
}
|
||||
|
||||
async function onRotateKey(keyId: string) {
|
||||
if (!confirm(t.get('automation.keys.rotate_confirm'))) return;
|
||||
|
||||
isRotatingKeyId = keyId;
|
||||
await automationApi.rotateKey(keyId);
|
||||
isRotatingKeyId = null;
|
||||
}
|
||||
|
||||
const mcpSecret = $derived(automationApi.generatedSecret);
|
||||
const mcpKeys = $derived(automationApi.keys.filter((key) => key.type === 'mcp'));
|
||||
const claudeCodeSnippet = $derived(getClaudeCodeSnippet(mcpSecret));
|
||||
const codexSnippet = $derived(getCodexSnippet(mcpSecret));
|
||||
const cursorSnippet = $derived(getCursorSnippet(mcpSecret));
|
||||
</script>
|
||||
|
||||
<Field.Group class="w-full max-w-5xl! space-y-8 px-2">
|
||||
{#if !$isOrgAdmin}
|
||||
<Alert.Callout
|
||||
variant="information"
|
||||
title={$t('automation.permissions.disabled_title')}
|
||||
description={$t('automation.permissions.disabled_description')}
|
||||
class="w-full"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<Tabs.Root bind:value={activeTab} class="w-full">
|
||||
<Tabs.List class="grid w-full grid-cols-3">
|
||||
<Tabs.Trigger value="mcp">{$t('automation.tabs.mcp')}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="zapier" disabled>
|
||||
<span>{$t('automation.tabs.zapier')}</span>
|
||||
<Badge variant="outline" class="text-[10px]!">{$t('automation.coming_soon')}</Badge>
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="api" disabled>
|
||||
<span>{$t('automation.tabs.api')}</span>
|
||||
<Badge variant="outline" class="text-[10px]!">{$t('automation.coming_soon')}</Badge>
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="mcp" class="space-y-8">
|
||||
<Field.Set>
|
||||
<Field.Legend class="flex items-center gap-2">
|
||||
<KeyIcon class="size-5" />
|
||||
{$t('automation.mcp.keys.title')}
|
||||
</Field.Legend>
|
||||
<Field.Description>{$t('automation.mcp.keys.description')}</Field.Description>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
onclick={onGenerateKey}
|
||||
variant="secondary"
|
||||
loading={automationApi.isLoading}
|
||||
disabled={!$isOrgAdmin || automationApi.isLoading}
|
||||
>
|
||||
{$t('automation.mcp.keys.generate')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if automationApi.generatedSecret}
|
||||
<div class="mt-2 rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||
<p class="ui:text-amber-800 text-sm font-medium">{$t('automation.mcp.keys.secret_once')}</p>
|
||||
<div class="mt-3">
|
||||
<Code.Root
|
||||
code={automationApi.generatedSecret}
|
||||
lang="bash"
|
||||
hideLines={true}
|
||||
class="ui:break-all ui:text-xs"
|
||||
>
|
||||
<Code.CopyButton />
|
||||
</Code.Root>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $isOrgAdmin}
|
||||
{#if mcpKeys.length > 0}
|
||||
<div class="mt-6 grid gap-4 md:grid-cols-2">
|
||||
{#each mcpKeys as key (key.id)}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<Card.Title>{key.label}</Card.Title>
|
||||
<Card.Description>{getMaskedAutomationSecret(key.secretPrefix)}</Card.Description>
|
||||
</div>
|
||||
<Badge variant={key.revokedAt ? 'secondary' : 'default'}>
|
||||
{key.revokedAt ? $t('automation.keys.revoked') : $t('automation.keys.active')}
|
||||
</Badge>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-2 text-sm">
|
||||
<p>
|
||||
<span class="font-medium">{$t('automation.keys.created_at_label')}</span>
|
||||
{new Date(key.createdAt).toLocaleString()}
|
||||
</p>
|
||||
<p>
|
||||
<span class="font-medium">{$t('automation.keys.last_used_label')}</span>
|
||||
{key.lastUsedAt ? new Date(key.lastUsedAt).toLocaleString() : $t('automation.keys.never_used')}
|
||||
</p>
|
||||
<p>
|
||||
<span class="font-medium">{$t('automation.keys.scopes_label')}</span>
|
||||
{key.scopes.join(', ')}
|
||||
</p>
|
||||
</Card.Content>
|
||||
<Card.Footer class="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onclick={() => onRotateKey(key.id)}
|
||||
loading={isRotatingKeyId === key.id}
|
||||
disabled={!$isOrgAdmin || Boolean(key.revokedAt)}
|
||||
>
|
||||
{$t('automation.keys.rotate')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onclick={() => onRevokeKey(key.id)}
|
||||
loading={isRevokingKeyId === key.id}
|
||||
disabled={!$isOrgAdmin || Boolean(key.revokedAt)}
|
||||
>
|
||||
{$t('automation.keys.revoke')}
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<Alert.Callout
|
||||
variant="information"
|
||||
title={$t('automation.keys.empty_title')}
|
||||
description={$t('automation.keys.empty_description')}
|
||||
class="mt-6 w-full"
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend class="flex items-center gap-2">
|
||||
<SparklesIcon class="size-5" />
|
||||
{$t('automation.mcp.setup.title')}
|
||||
</Field.Legend>
|
||||
<Field.Description>{$t('automation.mcp.setup.description')}</Field.Description>
|
||||
|
||||
<Tabs.Root bind:value={activeSetupTab} class="w-full">
|
||||
<Tabs.List class="inline-flex w-auto">
|
||||
<Tabs.Trigger value="claude-code">{$t('automation.clients.claude_code')}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="codex">{$t('automation.clients.codex')}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="cursor">{$t('automation.clients.cursor')}</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="claude-code" class="mt-4">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{$t('automation.clients.claude_code')}</Card.Title>
|
||||
<Card.Description>{$t('automation.mcp.setup.claude_code_description')}</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<Code.FileName filename="terminal" />
|
||||
<Code.Overflow>
|
||||
<Code.Root code={claudeCodeSnippet} lang="bash" class="ui:w-full ui:border-none">
|
||||
<Code.CopyButton />
|
||||
</Code.Root>
|
||||
</Code.Overflow>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="codex" class="mt-4">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{$t('automation.clients.codex')}</Card.Title>
|
||||
<Card.Description>{$t('automation.mcp.setup.codex_description')}</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<Code.FileName filename="terminal" />
|
||||
<Code.Overflow>
|
||||
<Code.Root code={codexSnippet} lang="bash" class="ui:w-full ui:border-none">
|
||||
<Code.CopyButton />
|
||||
</Code.Root>
|
||||
</Code.Overflow>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="cursor" class="mt-4">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{$t('automation.clients.cursor')}</Card.Title>
|
||||
<Card.Description>{$t('automation.mcp.setup.cursor_description')}</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<Code.FileName filename="mcp.json" />
|
||||
<Code.Overflow>
|
||||
<Code.Root code={cursorSnippet} lang="json" class="ui:w-full ui:border-none">
|
||||
<Code.CopyButton />
|
||||
</Code.Root>
|
||||
</Code.Overflow>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</Field.Set>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="zapier" class="mt-6">
|
||||
<Alert.Callout
|
||||
variant="information"
|
||||
title={$t('automation.tabs.zapier')}
|
||||
description={$t('automation.coming_soon')}
|
||||
class="w-full"
|
||||
/>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="api" class="mt-6">
|
||||
<Alert.Callout
|
||||
variant="information"
|
||||
title={$t('automation.tabs.api')}
|
||||
description={$t('automation.coming_soon')}
|
||||
class="w-full"
|
||||
/>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</Field.Group>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as AutomationPage } from './automation.svelte';
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { AutomationKeyType } from './types';
|
||||
|
||||
function getServerPath() {
|
||||
return 'npx -y @classroomio/mcp';
|
||||
}
|
||||
|
||||
export function getAutomationSetupSecret(secret: string | null) {
|
||||
return secret ?? '<paste-your-mcp-key>';
|
||||
}
|
||||
|
||||
export function getClaudeCodeSnippet(secret: string | null) {
|
||||
const apiKey = getAutomationSetupSecret(secret);
|
||||
|
||||
return `claude mcp add-json classroomio '{
|
||||
"command": "npx",
|
||||
"args": ["-y", "@classroomio/mcp"],
|
||||
"env": {
|
||||
"CLASSROOMIO_API_URL": "https://api.classroomio.com",
|
||||
"CLASSROOMIO_API_KEY": "${apiKey}"
|
||||
}
|
||||
}'`;
|
||||
}
|
||||
|
||||
export function getCodexSnippet(secret: string | null) {
|
||||
const apiKey = getAutomationSetupSecret(secret);
|
||||
|
||||
return `codex mcp add classroomio \\
|
||||
--env CLASSROOMIO_API_URL=https://api.classroomio.com \\
|
||||
--env CLASSROOMIO_API_KEY=${apiKey} \\
|
||||
-- npx -y @classroomio/mcp`;
|
||||
}
|
||||
|
||||
export function getCursorSnippet(secret: string | null) {
|
||||
const apiKey = getAutomationSetupSecret(secret);
|
||||
|
||||
return `{
|
||||
"mcpServers": {
|
||||
"classroomio": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@classroomio/mcp"],
|
||||
"env": {
|
||||
"CLASSROOMIO_API_URL": "https://api.classroomio.com",
|
||||
"CLASSROOMIO_API_KEY": "${apiKey}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
}
|
||||
|
||||
export function getDefaultAutomationKeyLabel(type: AutomationKeyType) {
|
||||
switch (type) {
|
||||
case 'mcp':
|
||||
return 'ClassroomIO MCP';
|
||||
case 'api':
|
||||
return 'ClassroomIO API';
|
||||
case 'zapier':
|
||||
return 'ClassroomIO Zapier';
|
||||
}
|
||||
}
|
||||
|
||||
export function getAutomationKeyTypeLabel(type: AutomationKeyType) {
|
||||
switch (type) {
|
||||
case 'mcp':
|
||||
return 'MCP';
|
||||
case 'api':
|
||||
return 'API';
|
||||
case 'zapier':
|
||||
return 'Zapier';
|
||||
}
|
||||
}
|
||||
|
||||
export function getMaskedAutomationSecret(prefix: string) {
|
||||
return `${prefix}...`;
|
||||
}
|
||||
|
||||
export function getCopyableServerCommand() {
|
||||
return getServerPath();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
|
||||
|
||||
export type ListAutomationKeysRequest = (typeof classroomio.organization)['automation']['keys']['$get'];
|
||||
export type CreateAutomationKeyRequest = (typeof classroomio.organization)['automation']['keys']['$post'];
|
||||
export type RevokeAutomationKeyRequest = (typeof classroomio.organization)['automation']['keys'][':keyId']['$delete'];
|
||||
export type RotateAutomationKeyRequest =
|
||||
(typeof classroomio.organization)['automation']['keys'][':keyId']['rotate']['$post'];
|
||||
|
||||
export type ListAutomationKeysSuccess = Extract<InferResponseType<ListAutomationKeysRequest>, { success: true }>;
|
||||
export type CreateAutomationKeySuccess = Extract<InferResponseType<CreateAutomationKeyRequest>, { success: true }>;
|
||||
export type RevokeAutomationKeySuccess = Extract<InferResponseType<RevokeAutomationKeyRequest>, { success: true }>;
|
||||
export type RotateAutomationKeySuccess = Extract<InferResponseType<RotateAutomationKeyRequest>, { success: true }>;
|
||||
|
||||
export type AutomationKey = ListAutomationKeysSuccess['data'][number];
|
||||
export type AutomationKeyType = AutomationKey['type'];
|
||||
@@ -14,7 +14,7 @@ import { currentOrg as currentOrgStore } from '$lib/utils/store/org';
|
||||
import type { TCreateCommunityQuestion } from '@cio/utils/validation/community';
|
||||
import { coursesApi } from '$features/course/api/courses.svelte';
|
||||
import { currentCommunityQuestion } from '../utils/store';
|
||||
import generateSlug from '$lib/utils/functions/generateSlug';
|
||||
import { generateSlug } from '@cio/utils/functions';
|
||||
import { get } from 'svelte/store';
|
||||
import { basePath, isStudentExperience } from '$lib/utils/store/app';
|
||||
import { goto } from '$app/navigation';
|
||||
@@ -121,7 +121,7 @@ class CommunityApi extends BaseApiWithErrors {
|
||||
return;
|
||||
}
|
||||
|
||||
const slug = generateSlug(fields.title);
|
||||
const slug = generateSlug(fields.title, { appendTimestamp: true });
|
||||
const currentOrg = get(currentOrgStore);
|
||||
const profile = get(profileStore);
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { CloneCourseRequest } from '../utils/types';
|
||||
import type { TCourseClone } from '@cio/utils/validation/course';
|
||||
import { ZCourseClone } from '@cio/utils/validation/course';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import generateSlug from '$lib/utils/functions/generateSlug';
|
||||
import { generateSlug } from '@cio/utils/functions';
|
||||
import { get } from 'svelte/store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
||||
@@ -26,7 +26,7 @@ export class CourseCloneApi extends BaseApiWithErrors {
|
||||
*/
|
||||
async clone(courseId: string, title: string, description?: string) {
|
||||
// Generate unique slug from the title
|
||||
const slug = generateSlug(title);
|
||||
const slug = generateSlug(title, { appendTimestamp: true });
|
||||
|
||||
// Prepare data for validation
|
||||
const data: TCourseClone = {
|
||||
|
||||
@@ -206,7 +206,7 @@
|
||||
const isExerciseUnlocked = $derived(exerciseContentItem?.isUnlocked ?? false);
|
||||
</script>
|
||||
|
||||
<Page.Header isSticky={true} class="min-h-[36px]">
|
||||
<Page.Header isSticky={true} class="z-250! min-h-[36px]">
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$questionnaire.title || 'Exercise'}</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
|
||||
import type { AccountOrg } from '$features/app/types';
|
||||
import type { Component } from 'svelte';
|
||||
import BracesIcon from '@lucide/svelte/icons/braces';
|
||||
import TagIcon from '@lucide/svelte/icons/tag';
|
||||
import { isActive } from '$lib/utils/functions/app';
|
||||
|
||||
@@ -22,6 +23,7 @@ export interface NavItem {
|
||||
isExpanded?: boolean;
|
||||
items?: NavItem[]; // for nested items like settings
|
||||
isPaid?: boolean; // Show upgrade indicator for free plan users
|
||||
disabled?: boolean;
|
||||
// Metadata for breadcrumb generation
|
||||
useHashUrl?: boolean; // Use '#' as URL (for collapsible items like settings)
|
||||
nestedRoutes?: NestedRouteConfig[]; // Static nested routes (like community/ask, settings/customize-lms)
|
||||
@@ -33,6 +35,7 @@ export interface NavItemConfig {
|
||||
path: string;
|
||||
icon?: Component;
|
||||
requiresAdmin?: boolean;
|
||||
disableWhenNotAdmin?: boolean;
|
||||
items?: NavItemConfig[];
|
||||
useHashUrl?: boolean; // Use '#' as URL (for collapsible items like settings)
|
||||
nestedRoutes?: NestedRouteConfig[]; // Static nested routes
|
||||
@@ -99,6 +102,14 @@ const baseNavConfig: NavItemConfig[] = [
|
||||
requiresAdmin: true,
|
||||
matchPattern: '^/org/[^/]+/setup(/.*)?$' // Matches nested routes
|
||||
},
|
||||
{
|
||||
titleKey: 'org_navigation.automation',
|
||||
path: '/automation',
|
||||
icon: BracesIcon,
|
||||
requiresAdmin: true,
|
||||
disableWhenNotAdmin: true,
|
||||
matchPattern: '^/org/[^/]+/automation(/.*)?$'
|
||||
},
|
||||
{
|
||||
titleKey: 'org_navigation.settings',
|
||||
path: '/settings',
|
||||
@@ -172,7 +183,7 @@ export function getOrgNavigationItems(
|
||||
|
||||
for (const config of baseNavConfig) {
|
||||
// Skip admin-only items if user is not admin
|
||||
if (config.requiresAdmin && !isOrgAdmin) {
|
||||
if (config.requiresAdmin && !isOrgAdmin && !config.disableWhenNotAdmin) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -190,6 +201,7 @@ export function getOrgNavigationItems(
|
||||
icon: config.icon,
|
||||
isActive: isActive(pagePathname, fullPath, matchPattern),
|
||||
isExpanded: config.items ? isActive(pagePathname, fullPath, matchPattern) : undefined,
|
||||
disabled: Boolean(config.disableWhenNotAdmin && !isOrgAdmin),
|
||||
useHashUrl: config.useHashUrl,
|
||||
nestedRoutes: config.nestedRoutes,
|
||||
supportsDynamicSegment: config.supportsDynamicSegment,
|
||||
|
||||
@@ -27,21 +27,37 @@
|
||||
{#snippet child({ props })}
|
||||
<HoverableItem>
|
||||
{#snippet children(isHovered)}
|
||||
<a href={item.url} {...props}>
|
||||
{#if item.icon}
|
||||
{@const Icon = item.icon}
|
||||
<Icon {isHovered} size={16} class="custom" />
|
||||
{#if item.disabled}
|
||||
<span
|
||||
{...props}
|
||||
aria-disabled="true"
|
||||
class="flex cursor-not-allowed items-center gap-2 opacity-50"
|
||||
>
|
||||
{#if item.icon}
|
||||
{@const Icon = item.icon}
|
||||
<Icon {isHovered} size={16} class="custom" />
|
||||
<span>{item.title}</span>
|
||||
{:else}
|
||||
<span>{item.title}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<a href={item.url} {...props}>
|
||||
{#if item.icon}
|
||||
{@const Icon = item.icon}
|
||||
<Icon {isHovered} size={16} class="custom" />
|
||||
|
||||
<span>{item.title}</span>
|
||||
{:else}
|
||||
<span>{item.title}</span>
|
||||
{/if}
|
||||
{#if item.items}
|
||||
<ChevronRightIcon
|
||||
class="custom ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
|
||||
/>
|
||||
{/if}
|
||||
</a>
|
||||
<span>{item.title}</span>
|
||||
{:else}
|
||||
<span>{item.title}</span>
|
||||
{/if}
|
||||
{#if item.items}
|
||||
<ChevronRightIcon
|
||||
class="custom ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
|
||||
/>
|
||||
{/if}
|
||||
</a>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</HoverableItem>
|
||||
{/snippet}
|
||||
|
||||
@@ -29,8 +29,7 @@ export const PUBLIC_ROUTES = [
|
||||
ROUTE.FORGOT,
|
||||
ROUTE.RESET,
|
||||
`^${ROUTE.PAGES}/.*`,
|
||||
// Course landing page only (excludes COURSE_ENROLL which requires auth)
|
||||
`^${ROUTE.COURSE}/[^/]+/?$`,
|
||||
`^/course/.*/enroll$`,
|
||||
`^${ROUTE.COURSES}/?$`,
|
||||
'/404',
|
||||
`^${ROUTE.VERIFY_EMAIL_ERROR}$`,
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
function generateSlug(title) {
|
||||
return title
|
||||
.match(/[^\W\s\d]*/g, '')
|
||||
.filter((v = '') => !!v.trim())
|
||||
.join('-')
|
||||
.toLowerCase()
|
||||
.concat(`-${new Date().getTime()}`);
|
||||
}
|
||||
|
||||
export default generateSlug;
|
||||
@@ -2474,32 +2474,17 @@
|
||||
"subtitle": "Forbind MCP-klienter nu, med Zapier og den offentlige API, der følger på det samme nøglesystem med organisationsomfang.",
|
||||
"coming_soon": "Kommer snart",
|
||||
"mcp": {
|
||||
"architecture": {
|
||||
"title": "Arkitektur",
|
||||
"description": "MCP-klienten og ClassroomIO API er separate tillidsgrænser. Den org-omfattede automatiseringsnøgle autentificerer klienten, og API'et forbliver kilden til sandhed for validering og vedholdenhed."
|
||||
},
|
||||
"flows": {
|
||||
"title": "Typiske flows",
|
||||
"description": "Agenten ejer ræsonnement og struktur. ClassroomIO accepterer kun normaliserede kurser, gemmer kladder og udgiver efter anmodning.",
|
||||
"prompt_title": "Spørg om udkast",
|
||||
"prompt_description": "Brugeren beder om et kursus, agenten strukturerer det, og 'create_course_draft' gemmer det i ClassroomIO før ethvert udgivelsestrin.",
|
||||
"pdf_title": "PDF til udkast",
|
||||
"pdf_description": "Agenten læser PDF'en eksternt, omstrukturerer den i sektioner og lektioner og sender derefter den normaliserede kladde til ClassroomIO.",
|
||||
"review_title": "Anmeldelse for at offentliggøre",
|
||||
"review_description": "Brugeren beder om ændringer, agenten opdaterer kladden, og publicering sker kun efter eksplicit godkendelse."
|
||||
},
|
||||
"setup": {
|
||||
"title": "Klient opsætning",
|
||||
"description": "Brug én nøgle med organisationsomfang pr. integration. Hvis en nøgle roteres eller tilbagekaldes, holder klienten op med at arbejde, indtil den nye hemmelighed er konfigureret.",
|
||||
"claude_code_description": "Registrerer `@cio/mcp` med Claude Code ved hjælp af den genererede org-omfangede nøgle.",
|
||||
"claude_code_description": "Registrerer `@classroomio/mcp` med Claude Code ved hjælp af den genererede org-omfangede nøgle.",
|
||||
"codex_description": "Tilføjer ClassroomIO MCP-serveren til Codex med den samme org-omfattede nøgle.",
|
||||
"cursor_description": "Indsæt dette i `~/.cursor/mcp.json` eller dit arbejdsområde MCP-konfiguration."
|
||||
},
|
||||
"keys": {
|
||||
"title": "MCP nøgler",
|
||||
"description": "Disse nøgler autentificerer den aktuelle organisation, når en MCP-klient ringer til ClassroomIO gennem `@cio/mcp`.",
|
||||
"description": "Disse nøgler autentificerer den aktuelle organisation, når en MCP-klient ringer til ClassroomIO gennem `@classroomio/mcp`.",
|
||||
"generate": "Generer MCP-nøgle",
|
||||
"scope_badge": "Udkast opret/læs/opdater/udgiv",
|
||||
"secret_once": "Denne hemmelighed vises én gang. Kopier det til din MCP-klient nu."
|
||||
}
|
||||
},
|
||||
@@ -2530,10 +2515,6 @@
|
||||
"permissions": {
|
||||
"disabled_title": "Skrivebeskyttet adgang",
|
||||
"disabled_description": "Kun organisationsadministratorer kan oprette, rotere eller tilbagekalde automatiseringsnøgler. Lærere kan gennemgå opsætningsinstruktionerne her."
|
||||
},
|
||||
"overview": {
|
||||
"title": "Automatiseringsadgang",
|
||||
"description": "Opret org-omfattede automatiseringsnøgler, tilslut understøttede klienter, og hold al maskinadgang bundet tilbage til denne organisation."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2474,32 +2474,17 @@
|
||||
"subtitle": "Verbinden Sie jetzt MCP-Clients, wobei Zapier und die öffentliche API auf demselben organisationsweiten Schlüsselsystem folgen.",
|
||||
"coming_soon": "Kommt bald",
|
||||
"mcp": {
|
||||
"architecture": {
|
||||
"title": "Architektur",
|
||||
"description": "Der MCP-Client und die ClassroomIO-API sind separate Vertrauensgrenzen. Der organisationsweite Automatisierungsschlüssel authentifiziert den Client und die API bleibt die Quelle der Wahrheit für Validierung und Persistenz."
|
||||
},
|
||||
"flows": {
|
||||
"title": "Typische Strömungen",
|
||||
"description": "Der Agent besitzt Argumentation und Struktur. ClassroomIO akzeptiert nur normalisierte Kursnutzlasten, speichert Entwürfe und veröffentlicht auf Anfrage.",
|
||||
"prompt_title": "Aufforderung zum Entwurf",
|
||||
"prompt_description": "Der Benutzer fragt nach einem Kurs, der Agent strukturiert ihn und „create_course_draft“ speichert ihn dann vor jedem Veröffentlichungsschritt in ClassroomIO.",
|
||||
"pdf_title": "PDF zum Entwurf",
|
||||
"pdf_description": "Der Agent liest das PDF extern, strukturiert es in Abschnitte und Lektionen um und sendet dann den normalisierten Entwurf an ClassroomIO.",
|
||||
"review_title": "Rezension zur Veröffentlichung",
|
||||
"review_description": "Der Benutzer bittet um Änderungen, der Agent aktualisiert den Entwurf und die Veröffentlichung erfolgt erst nach ausdrücklicher Genehmigung."
|
||||
},
|
||||
"setup": {
|
||||
"title": "Client-Setup",
|
||||
"description": "Verwenden Sie einen organisationsweiten Schlüssel pro Integration. Wenn ein Schlüssel rotiert oder widerrufen wird, funktioniert der Client nicht mehr, bis das neue Geheimnis konfiguriert ist.",
|
||||
"claude_code_description": "Registriert „@cio/mcp“ bei Claude Code unter Verwendung des generierten organisationsweit gültigen Schlüssels.",
|
||||
"claude_code_description": "Registriert „@classroomio/mcp“ bei Claude Code unter Verwendung des generierten organisationsweit gültigen Schlüssels.",
|
||||
"codex_description": "Fügt den ClassroomIO MCP-Server mit demselben organisationsweit gültigen Schlüssel zu Codex hinzu.",
|
||||
"cursor_description": "Fügen Sie dies in „~/.cursor/mcp.json“ oder Ihre Workspace-MCP-Konfiguration ein."
|
||||
},
|
||||
"keys": {
|
||||
"title": "MCP-Schlüssel",
|
||||
"description": "Diese Schlüssel authentifizieren die aktuelle Organisation, wenn ein MCP-Client ClassroomIO über „@cio/mcp“ aufruft.",
|
||||
"description": "Diese Schlüssel authentifizieren die aktuelle Organisation, wenn ein MCP-Client ClassroomIO über „@classroomio/mcp“ aufruft.",
|
||||
"generate": "MCP-Schlüssel generieren",
|
||||
"scope_badge": "Entwurf erstellen/lesen/aktualisieren/veröffentlichen",
|
||||
"secret_once": "Dieses Geheimnis wird einmal gezeigt. Kopieren Sie es jetzt in Ihren MCP-Client."
|
||||
}
|
||||
},
|
||||
@@ -2530,10 +2515,6 @@
|
||||
"permissions": {
|
||||
"disabled_title": "Nur lesender Zugriff",
|
||||
"disabled_description": "Nur Organisationsadministratoren können Automatisierungsschlüssel erstellen, rotieren oder widerrufen. Lehrer können die Einrichtungsanweisungen hier einsehen."
|
||||
},
|
||||
"overview": {
|
||||
"title": "Automatisierungszugriff",
|
||||
"description": "Erstellen Sie organisationsweite Automatisierungsschlüssel, verbinden Sie unterstützte Clients und halten Sie den gesamten Maschinenzugriff an diese Organisation gebunden."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"seconds": "seconds",
|
||||
"snackbar_error": "error while sending code"
|
||||
},
|
||||
|
||||
"email_verification": {
|
||||
"errors": {
|
||||
"invalid_token": {
|
||||
@@ -52,21 +51,18 @@
|
||||
},
|
||||
"security_footer": "Your account security is our top priority. All verification attempts are logged for security monitoring."
|
||||
},
|
||||
|
||||
"delete_modal": {
|
||||
"label": "Delete",
|
||||
"content": "Are you sure?",
|
||||
"no": "No",
|
||||
"yes": "Yes"
|
||||
},
|
||||
|
||||
"new_course_modal": {
|
||||
"live_class_label": "Live Class",
|
||||
"live_class_subtitle": "Ideal for bootcamps where lessons are time based",
|
||||
"self_paced_label": "Self Paced",
|
||||
"self_paced_subtitle": "Best for courses where students can take lessons at their own pace"
|
||||
},
|
||||
|
||||
"onboarding": {
|
||||
"sell": "Sell courses online",
|
||||
"teach": "Teach my current students online",
|
||||
@@ -86,20 +82,17 @@
|
||||
"back": "Back",
|
||||
"continue": "Continue"
|
||||
},
|
||||
|
||||
"welcome_modal": {
|
||||
"we_at": "We at",
|
||||
"small_team": "are a small team with a large mission to empower educators with great software to help them make an impact in people's lives. Education is the most powerful gift you can give anyone, and it makes perfect sense to provide you with all the tools you need to give this GIFT to more people.",
|
||||
"thank_you": "Thank you for choosing us",
|
||||
"deeply_appreciate": "we deeply appreciate it and are at your service."
|
||||
},
|
||||
|
||||
"app": {
|
||||
"search": {
|
||||
"placeholder": "Search"
|
||||
}
|
||||
},
|
||||
|
||||
"login": {
|
||||
"welcome": "Welcome back",
|
||||
"email": "Your email",
|
||||
@@ -142,7 +135,6 @@
|
||||
"timer": "Activity timer",
|
||||
"tic_tac": "Tictac toe",
|
||||
"see_more": "See more",
|
||||
|
||||
"whats_new": "What's new",
|
||||
"launch_week": "Launch week"
|
||||
},
|
||||
@@ -365,7 +357,6 @@
|
||||
"video": "Video",
|
||||
"image": "Image"
|
||||
},
|
||||
|
||||
"show_banner": "Show banner",
|
||||
"hide_banner": "Hide banner"
|
||||
},
|
||||
@@ -422,7 +413,6 @@
|
||||
"twitter": "Twitter",
|
||||
"linkedin": "LinkedIn"
|
||||
},
|
||||
|
||||
"custom_links": {
|
||||
"heading": "Custom Links",
|
||||
"hide_links": "Hide Links",
|
||||
@@ -458,7 +448,6 @@
|
||||
"error": "An error occurred while opening billing"
|
||||
}
|
||||
},
|
||||
|
||||
"media_manager": {
|
||||
"page_title": "Media Manager - ClassroomIO",
|
||||
"heading": "Media Manager",
|
||||
@@ -567,7 +556,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"tags_admin": {
|
||||
"page_title": "Tags - ClassroomIO",
|
||||
"heading": "Tags",
|
||||
@@ -617,7 +605,6 @@
|
||||
"no_groups": "Create a tag group before creating tags"
|
||||
}
|
||||
},
|
||||
|
||||
"public_courses": {
|
||||
"page_title": "Courses - ClassroomIO",
|
||||
"heading": "Courses",
|
||||
@@ -633,7 +620,6 @@
|
||||
"description": "Try clearing your filters or check back later."
|
||||
}
|
||||
},
|
||||
|
||||
"org_navigation": {
|
||||
"platform": "Platform",
|
||||
"dashboard": "Dashboard",
|
||||
@@ -650,7 +636,6 @@
|
||||
"unlock": "Unlock unlimited features and invest in our future",
|
||||
"upgrade": "Upgrade Now"
|
||||
},
|
||||
|
||||
"pricing": {
|
||||
"plan_names": {
|
||||
"free": "Free",
|
||||
@@ -671,7 +656,6 @@
|
||||
"per_org": "Per Organization"
|
||||
}
|
||||
},
|
||||
|
||||
"setup": {
|
||||
"your_checklist": "Your Checklist",
|
||||
"get_started": "Get Started",
|
||||
@@ -711,7 +695,6 @@
|
||||
"button_label": "Publish Course"
|
||||
}
|
||||
},
|
||||
|
||||
"add_org": {
|
||||
"create_org": "Create Organization",
|
||||
"name": "Name of Organization",
|
||||
@@ -720,7 +703,6 @@
|
||||
"sitename": "Sitename already exists",
|
||||
"error_organization": "Something went wrong while creating this organization. Please reload and try again"
|
||||
},
|
||||
|
||||
"navigation": {
|
||||
"goto": "Go to",
|
||||
"courses": "Courses",
|
||||
@@ -739,12 +721,10 @@
|
||||
"org_loading_state": "Taking you to your organization...",
|
||||
"add_organization": "+ Add Organization"
|
||||
},
|
||||
|
||||
"markdown_editor": {
|
||||
"write": "Write",
|
||||
"preview": "Preview"
|
||||
},
|
||||
|
||||
"components": {
|
||||
"quiz": {
|
||||
"title": "Quizzes",
|
||||
@@ -825,7 +805,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"schedule": {
|
||||
"title": "Your Schedule",
|
||||
"essence": "The essence of design collaboration",
|
||||
@@ -833,7 +812,6 @@
|
||||
"join": "Join Call",
|
||||
"no": "No Lesson on this day"
|
||||
},
|
||||
|
||||
"dashboard": {
|
||||
"morning_heading": "Good Morning",
|
||||
"afternoon_heading": "Good Afternoon",
|
||||
@@ -868,7 +846,6 @@
|
||||
"publish_first_course_description": "To track enrollments, publish a course and invite students",
|
||||
"publish_course": "Publish Course"
|
||||
},
|
||||
|
||||
"ai": {
|
||||
"help_me": "Help me write",
|
||||
"placeholder": "Help me write a short description",
|
||||
@@ -878,7 +855,6 @@
|
||||
"insert": "Insert",
|
||||
"can_you": "Can you please rephrase this"
|
||||
},
|
||||
|
||||
"courses": {
|
||||
"heading": "Courses",
|
||||
"heading_button": "Create Course",
|
||||
@@ -947,7 +923,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"course": {
|
||||
"navItems": {
|
||||
"nav_news_feed": "News Feed",
|
||||
@@ -972,7 +947,6 @@
|
||||
"placeholder": "Search lessons or exercises",
|
||||
"empty": "No results found"
|
||||
},
|
||||
|
||||
"navItem": {
|
||||
"news_feed": {
|
||||
"heading": "News Feed",
|
||||
@@ -1015,7 +989,6 @@
|
||||
"fetching_error": "An error occured while fetching feed"
|
||||
}
|
||||
},
|
||||
|
||||
"lessons": {
|
||||
"heading": "Lessons",
|
||||
"heading_v2": "Content",
|
||||
@@ -1078,7 +1051,6 @@
|
||||
"no_tutor": "No tutor added",
|
||||
"body_header": "No lessons yet",
|
||||
"body_content": "Share your knowledge with the world by creating engaging lessons. Start by clicking on the Add button.",
|
||||
|
||||
"lesson_nav": {
|
||||
"materials": "Materials",
|
||||
"exercises": "Exercises"
|
||||
@@ -1179,7 +1151,6 @@
|
||||
"upload_error_try_again": "Please try again with a valid document file",
|
||||
"try_again": "Try Again",
|
||||
"upgrade": "Upgrade your plan to upload documents",
|
||||
|
||||
"cancel_upload": "Cancel Upload",
|
||||
"upload_cancelled": "Upload cancelled by user",
|
||||
"downloading": "Downloading...",
|
||||
@@ -1197,7 +1168,6 @@
|
||||
},
|
||||
"button_done": "Done"
|
||||
},
|
||||
|
||||
"exercises": {
|
||||
"heading": "All Exercises",
|
||||
"add_button": "Add",
|
||||
@@ -1554,7 +1524,6 @@
|
||||
"load_more_btn": "Load more",
|
||||
"comment_error": "Failed to post comment. Please try again."
|
||||
},
|
||||
|
||||
"polls": {
|
||||
"title": "Poll",
|
||||
"active_polls": "Active Polls",
|
||||
@@ -1562,7 +1531,6 @@
|
||||
"loading": "Loading...",
|
||||
"no_polls": "No polls to display"
|
||||
},
|
||||
|
||||
"poll": {
|
||||
"voted": "Voted",
|
||||
"created_by": "Created by",
|
||||
@@ -1591,7 +1559,6 @@
|
||||
"restore_version": "Restore this version"
|
||||
}
|
||||
},
|
||||
|
||||
"attendance": {
|
||||
"title": "Attendance",
|
||||
"present": "Present",
|
||||
@@ -1601,7 +1568,6 @@
|
||||
"lesson": "Lesson",
|
||||
"no_student": "No Student Added"
|
||||
},
|
||||
|
||||
"submissions": {
|
||||
"title": "Submitted Exercises",
|
||||
"submission_status": {
|
||||
@@ -1640,7 +1606,6 @@
|
||||
"delete_success": "Successfully deleted submission"
|
||||
}
|
||||
},
|
||||
|
||||
"marks": {
|
||||
"title": "Marks",
|
||||
"student": "Student",
|
||||
@@ -1652,7 +1617,6 @@
|
||||
},
|
||||
"no_student": "No Student Added"
|
||||
},
|
||||
|
||||
"people": {
|
||||
"title": "People",
|
||||
"add": "Add",
|
||||
@@ -1761,7 +1725,6 @@
|
||||
"remove": "Remove"
|
||||
}
|
||||
},
|
||||
|
||||
"certificates": {
|
||||
"title": "Certificates",
|
||||
"certificate_settings": "Certificate Settings",
|
||||
@@ -1789,7 +1752,6 @@
|
||||
"characters": "characters",
|
||||
"unexpected_error": "An unexpected error occurred"
|
||||
},
|
||||
|
||||
"landing_page": {
|
||||
"editor": {
|
||||
"title": {
|
||||
@@ -1802,12 +1764,10 @@
|
||||
"pricing": "Pricing",
|
||||
"certificate": "Certificate"
|
||||
},
|
||||
|
||||
"save": "Save",
|
||||
"page_builder": "Page Builder",
|
||||
"section": "section",
|
||||
"display_section": "Display section",
|
||||
|
||||
"header_form": {
|
||||
"title": "Title",
|
||||
"description": "Description",
|
||||
@@ -1816,7 +1776,6 @@
|
||||
"replace": "Replace",
|
||||
"helper": "Enter a link to a youtube video"
|
||||
},
|
||||
|
||||
"reviews_form": {
|
||||
"add_reviews": "Add Reviews",
|
||||
"fullname": "Full Name",
|
||||
@@ -1838,11 +1797,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"certificate_form": {
|
||||
"upload_template": "Certificate template"
|
||||
},
|
||||
|
||||
"instructor_form": {
|
||||
"upload": "Instructor Logo",
|
||||
"name": "Instructor name",
|
||||
@@ -1852,7 +1809,6 @@
|
||||
"about_placeholder": "A short background about the instructure",
|
||||
"total": "Total number of courses"
|
||||
},
|
||||
|
||||
"pricing_form": {
|
||||
"currency": "Currency",
|
||||
"cost": "Cost",
|
||||
@@ -1864,7 +1820,6 @@
|
||||
"gift": "About Gift"
|
||||
}
|
||||
},
|
||||
|
||||
"upload_widget": {
|
||||
"title": "Banner Widget",
|
||||
"width": "Images wider than 1500 pixels work best.",
|
||||
@@ -1873,7 +1828,6 @@
|
||||
"submit": "Submit",
|
||||
"no_images": "No images available."
|
||||
},
|
||||
|
||||
"pricing_section": {
|
||||
"free": "Free",
|
||||
"not_accepting": "This course is currently not accepting new students.",
|
||||
@@ -1887,7 +1841,6 @@
|
||||
"back_to_course": "Back to course",
|
||||
"join_course": "Join Course"
|
||||
},
|
||||
|
||||
"course_content": "Course content",
|
||||
"modules": "modules",
|
||||
"lessons": "lessons",
|
||||
@@ -1923,7 +1876,6 @@
|
||||
"explore": "Explore courses",
|
||||
"find": "Find courses you will love from best teachers all over the world",
|
||||
"find_course": "Find a course...",
|
||||
|
||||
"reviews_modal": {
|
||||
"title": "Reviews",
|
||||
"rating": "Rating",
|
||||
@@ -1932,11 +1884,9 @@
|
||||
"instructor": "Instructor",
|
||||
"courses": "courses"
|
||||
},
|
||||
|
||||
"analytics": {
|
||||
"title": "Analytics"
|
||||
},
|
||||
|
||||
"settings": {
|
||||
"heading": "Settings",
|
||||
"cover_image": "Cover Image",
|
||||
@@ -1987,14 +1937,12 @@
|
||||
"save": "Save Changes"
|
||||
}
|
||||
},
|
||||
|
||||
"not_permitted": {
|
||||
"header": "No Permission",
|
||||
"body": "Unfortunately you don't have access to this course.",
|
||||
"button": "Go home"
|
||||
}
|
||||
},
|
||||
|
||||
"community": {
|
||||
"title": "Community",
|
||||
"ask_button": "Ask Community",
|
||||
@@ -2027,7 +1975,6 @@
|
||||
},
|
||||
"all": "All"
|
||||
},
|
||||
|
||||
"audience": {
|
||||
"title": "Audience",
|
||||
"export": "Export",
|
||||
@@ -2038,7 +1985,6 @@
|
||||
"date_joined": "Date Joined",
|
||||
"upgrade": "Upgrade your plan to invite more students"
|
||||
},
|
||||
|
||||
"analytics": {
|
||||
"title": "Analytics",
|
||||
"courses": "Courses",
|
||||
@@ -2098,7 +2044,6 @@
|
||||
"no_data_default": "No data available",
|
||||
"no_data_default_description": "There is no data to display at this time."
|
||||
},
|
||||
|
||||
"upgrade": {
|
||||
"certificate": "Upgrade your plan to generate certificates",
|
||||
"team": "Upgrade your plan to add team members",
|
||||
@@ -2110,15 +2055,10 @@
|
||||
"required": "Upgrade Required",
|
||||
"enterprise_required": "This feature requires an Enterprise plan."
|
||||
},
|
||||
|
||||
"automation": {
|
||||
"title": "Automation",
|
||||
"subtitle": "Connect MCP clients now, with Zapier and the public API following on the same org-scoped key system.",
|
||||
"coming_soon": "Coming soon",
|
||||
"overview": {
|
||||
"title": "Automation access",
|
||||
"description": "Create org-scoped automation keys, connect supported clients, and keep all machine access tied back to this organization."
|
||||
},
|
||||
"permissions": {
|
||||
"disabled_title": "Read-only access",
|
||||
"disabled_description": "Only organization admins can create, rotate, or revoke automation keys. Teachers can review the setup instructions here."
|
||||
@@ -2150,35 +2090,19 @@
|
||||
"mcp": {
|
||||
"keys": {
|
||||
"title": "MCP keys",
|
||||
"description": "These keys authenticate the current organization when an MCP client calls ClassroomIO through `@cio/mcp`.",
|
||||
"description": "These keys authenticate the current organization when an MCP client calls ClassroomIO through `@classroomio/mcp`.",
|
||||
"generate": "Generate MCP key",
|
||||
"scope_badge": "Draft create/read/update/publish",
|
||||
"secret_once": "This secret is shown once. Copy it into your MCP client now."
|
||||
},
|
||||
"setup": {
|
||||
"title": "Client setup",
|
||||
"description": "Use one org-scoped key per integration. If a key is rotated or revoked, the client stops working until the new secret is configured.",
|
||||
"claude_code_description": "Registers `@cio/mcp` with Claude Code using the generated org-scoped key.",
|
||||
"claude_code_description": "Registers `@classroomio/mcp` with Claude Code using the generated org-scoped key.",
|
||||
"codex_description": "Adds the ClassroomIO MCP server to Codex with the same org-scoped key.",
|
||||
"cursor_description": "Paste this into `~/.cursor/mcp.json` or your workspace MCP config."
|
||||
},
|
||||
"flows": {
|
||||
"title": "Typical flows",
|
||||
"description": "The agent owns reasoning and structure. ClassroomIO only accepts normalized course payloads, saves drafts, and publishes on request.",
|
||||
"prompt_title": "Prompt to draft",
|
||||
"prompt_description": "The user asks for a course, the agent structures it, then `create_course_draft` saves it into ClassroomIO before any publish step.",
|
||||
"pdf_title": "PDF to draft",
|
||||
"pdf_description": "The agent reads the PDF externally, restructures it into sections and lessons, then sends the normalized draft into ClassroomIO.",
|
||||
"review_title": "Review to publish",
|
||||
"review_description": "The user asks for changes, the agent updates the draft, and publish only happens after explicit approval."
|
||||
},
|
||||
"architecture": {
|
||||
"title": "Architecture",
|
||||
"description": "The MCP client and ClassroomIO API are separate trust boundaries. The org-scoped automation key authenticates the client, and the API remains the source of truth for validation and persistence."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"lms_navigation": {
|
||||
"home": "Home",
|
||||
"my_learning": "My Learning",
|
||||
@@ -2188,7 +2112,6 @@
|
||||
"help": "Help",
|
||||
"settings": "Settings"
|
||||
},
|
||||
|
||||
"my_learning": {
|
||||
"heading": "My Learning",
|
||||
"progress": "In progress",
|
||||
@@ -2199,7 +2122,6 @@
|
||||
"not_completed": "No Course Completed",
|
||||
"any_course": "Any course that you complete will be displayed here"
|
||||
},
|
||||
|
||||
"exercises": {
|
||||
"heading": "Exercises",
|
||||
"not_submitted": "Not Submitted",
|
||||
@@ -2208,13 +2130,11 @@
|
||||
"graded": "Graded",
|
||||
"lesson": "Lesson"
|
||||
},
|
||||
|
||||
"explore": {
|
||||
"heading": "Explore our courses",
|
||||
"empty_heading": "No course available",
|
||||
"empty_description": "There are currently no published course available"
|
||||
},
|
||||
|
||||
"snackbar": {
|
||||
"success": "Success",
|
||||
"success_update": "Updated successfully",
|
||||
@@ -2251,7 +2171,6 @@
|
||||
"thumbnail_too_large": "Thumbnail image is too large",
|
||||
"thumbnail_invalid": "Invalid thumbnail file"
|
||||
},
|
||||
|
||||
"poll": {
|
||||
"error": {
|
||||
"creating_poll": "An error occurred while creating poll",
|
||||
@@ -2263,19 +2182,16 @@
|
||||
"vote_once": "You can only vote once"
|
||||
}
|
||||
},
|
||||
|
||||
"exercise": {
|
||||
"success": "Saved Successfully",
|
||||
"submission_updated": "Submission updated to",
|
||||
"error_fetching": "Fetching exercises failed, please reload the page"
|
||||
},
|
||||
|
||||
"materials": {
|
||||
"apology": "We apologise, there was an error saving your lesson.",
|
||||
"update_translations": "Updating translations failed",
|
||||
"creating_new": "Creating new translations failed"
|
||||
},
|
||||
|
||||
"newsfeed": {
|
||||
"success": {
|
||||
"edit": "Feed edited successfully",
|
||||
@@ -2287,7 +2203,6 @@
|
||||
"creating": "creating feed"
|
||||
}
|
||||
},
|
||||
|
||||
"people": {
|
||||
"error": {
|
||||
"missing_data": "Org data missing, please reload the page",
|
||||
@@ -2297,7 +2212,6 @@
|
||||
"copied": "Copied to clipboard"
|
||||
}
|
||||
},
|
||||
|
||||
"course_settings": {
|
||||
"success": {
|
||||
"download": "Download Complete",
|
||||
@@ -2315,7 +2229,6 @@
|
||||
"failed_deletion": "Deletion Failed, please try again later"
|
||||
}
|
||||
},
|
||||
|
||||
"lms": {
|
||||
"error": {
|
||||
"username_exists": "username already exists",
|
||||
@@ -2323,7 +2236,6 @@
|
||||
"try_again": "Please try again"
|
||||
}
|
||||
},
|
||||
|
||||
"team_members": {
|
||||
"invite_sent": "Invite Sent!",
|
||||
"user_exists": "User Already Exists",
|
||||
@@ -2331,7 +2243,6 @@
|
||||
"remove_fail": "Failed to remove user, please try again",
|
||||
"remove_success": "Member removed successfully"
|
||||
},
|
||||
|
||||
"tags": {
|
||||
"group_created": "Tag group created",
|
||||
"group_updated": "Tag group updated",
|
||||
@@ -2341,7 +2252,6 @@
|
||||
"fetch_failed": "Failed to load tags",
|
||||
"save_failed": "Failed to save tag changes"
|
||||
},
|
||||
|
||||
"landing_page_settings": {
|
||||
"error": {
|
||||
"file_size": "File Size must not be greater than 500kb",
|
||||
@@ -2353,7 +2263,6 @@
|
||||
"complete": "Complete :)"
|
||||
}
|
||||
},
|
||||
|
||||
"course": {
|
||||
"success": {
|
||||
"comment_deleted": "Comment Deleted",
|
||||
@@ -2371,13 +2280,11 @@
|
||||
"feed_fetch_error": "An error occured while fetching feed"
|
||||
}
|
||||
},
|
||||
|
||||
"attendance": {
|
||||
"error": {
|
||||
"reload": "Something isn't right. Please reload and take attendance again"
|
||||
}
|
||||
},
|
||||
|
||||
"lessons": {
|
||||
"lesson_created": "Lesson created successfully",
|
||||
"lesson_create_failed": "Failed to create lesson",
|
||||
@@ -2410,28 +2317,23 @@
|
||||
"exercise_not_complete": "Complete all exercises to mark as complete"
|
||||
}
|
||||
},
|
||||
|
||||
"marks": {
|
||||
"error": "Error fetching marks"
|
||||
},
|
||||
|
||||
"submissions": {
|
||||
"success": {
|
||||
"grading": "Grading saved and student notified"
|
||||
}
|
||||
},
|
||||
|
||||
"invite": {
|
||||
"failed_join": "Joining failed, please contact your admin"
|
||||
},
|
||||
|
||||
"community": {
|
||||
"error": {
|
||||
"try_again": "Error - Please try again later",
|
||||
"deleting_comments": "Error deleting comments",
|
||||
"deleting_question": "Error deleting question"
|
||||
},
|
||||
|
||||
"success": {
|
||||
"question_updated": "Question Updated!",
|
||||
"comment_submitted": "Comment Submitted!",
|
||||
@@ -2439,31 +2341,25 @@
|
||||
"question_submitted": "Question Submitted!"
|
||||
}
|
||||
},
|
||||
|
||||
"signup": {
|
||||
"disposable": "The email you provided is disposable"
|
||||
},
|
||||
|
||||
"upgrade": {
|
||||
"generate_fail": "Failed to generate success",
|
||||
"failed": "Failed, please try again later"
|
||||
},
|
||||
|
||||
"setup": {
|
||||
"info_lesson": "You Need to Create a lesson",
|
||||
"info_course": "You need to create a course"
|
||||
},
|
||||
|
||||
"generic": {
|
||||
"success_delete": "Deleted successfully",
|
||||
"try_again": "Error - Please try again later"
|
||||
}
|
||||
},
|
||||
|
||||
"specialization": {
|
||||
"course_tag": "Specialization"
|
||||
},
|
||||
|
||||
"invite": {
|
||||
"organization": {
|
||||
"page_title": "Join {orgName} on ClassroomIO",
|
||||
@@ -2481,18 +2377,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"content": {
|
||||
"heading": "Internationalization in SvelteKit",
|
||||
"toggle_label": "Select Preferred Language"
|
||||
},
|
||||
|
||||
"generic": {
|
||||
"locked": "Locked",
|
||||
"unlocked": "Unlocked",
|
||||
"loading": "Loading"
|
||||
},
|
||||
|
||||
"validations": {
|
||||
"user_profile": {
|
||||
"email": "Invalid email address",
|
||||
@@ -2619,7 +2512,6 @@
|
||||
"invalid_field": "Invalid value"
|
||||
}
|
||||
},
|
||||
|
||||
"common": {
|
||||
"unsaved_changes": {
|
||||
"message": "You have unsaved changes. Are you sure you want to leave?"
|
||||
|
||||
@@ -2474,32 +2474,17 @@
|
||||
"subtitle": "Conecte los clientes de MCP ahora, con Zapier y la API pública siguiendo el mismo sistema de claves con ámbito de organización.",
|
||||
"coming_soon": "Próximamente",
|
||||
"mcp": {
|
||||
"architecture": {
|
||||
"title": "Arquitectura",
|
||||
"description": "El cliente MCP y la API ClassroomIO son límites de confianza separados. La clave de automatización con ámbito de organización autentica al cliente y la API sigue siendo la fuente de verdad para la validación y la persistencia."
|
||||
},
|
||||
"flows": {
|
||||
"title": "Flujos típicos",
|
||||
"description": "El agente posee razonamiento y estructura. ClassroomIO solo acepta cargas útiles de cursos normalizados, guarda borradores y publica a pedido.",
|
||||
"prompt_title": "Solicitar borrador",
|
||||
"prompt_description": "El usuario solicita un curso, el agente lo estructura y luego `create_course_draft` lo guarda en ClassroomIO antes de cualquier paso de publicación.",
|
||||
"pdf_title": "PDF para borrador",
|
||||
"pdf_description": "El agente lee el PDF externamente, lo reestructura en secciones y lecciones y luego envía el borrador normalizado a ClassroomIO.",
|
||||
"review_title": "Revisar para publicar",
|
||||
"review_description": "El usuario solicita cambios, el agente actualiza el borrador y la publicación solo se realiza después de una aprobación explícita."
|
||||
},
|
||||
"setup": {
|
||||
"title": "Configuración del cliente",
|
||||
"description": "Utilice una clave con ámbito de organización por integración. Si se rota o revoca una clave, el cliente deja de funcionar hasta que se configura el nuevo secreto.",
|
||||
"claude_code_description": "Registra `@cio/mcp` con Claude Code utilizando la clave de alcance organizacional generada.",
|
||||
"claude_code_description": "Registra `@classroomio/mcp` con Claude Code utilizando la clave de alcance organizacional generada.",
|
||||
"codex_description": "Agrega el servidor ClassroomIO MCP al Codex con la misma clave de ámbito de organización.",
|
||||
"cursor_description": "Pegue esto en `~/.cursor/mcp.json` o en la configuración MCP de su espacio de trabajo."
|
||||
},
|
||||
"keys": {
|
||||
"title": "Claves MCP",
|
||||
"description": "Estas claves autentican la organización actual cuando un cliente MCP llama a ClassroomIO a través de `@cio/mcp`.",
|
||||
"description": "Estas claves autentican la organización actual cuando un cliente MCP llama a ClassroomIO a través de `@classroomio/mcp`.",
|
||||
"generate": "Generar clave MCP",
|
||||
"scope_badge": "Borrador de creación/lectura/actualización/publicación",
|
||||
"secret_once": "Este secreto se muestra una vez. Cópielo en su cliente MCP ahora."
|
||||
}
|
||||
},
|
||||
@@ -2530,10 +2515,6 @@
|
||||
"permissions": {
|
||||
"disabled_title": "Acceso de solo lectura",
|
||||
"disabled_description": "Solo los administradores de la organización pueden crear, rotar o revocar claves de automatización. Los profesores pueden revisar las instrucciones de configuración aquí."
|
||||
},
|
||||
"overview": {
|
||||
"title": "Acceso a la automatización",
|
||||
"description": "Cree claves de automatización específicas de la organización, conecte clientes compatibles y mantenga todo el acceso a las máquinas vinculado a esta organización."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2474,32 +2474,17 @@
|
||||
"subtitle": "Connectez les clients MCP maintenant, avec Zapier et l'API publique qui suivent sur le même système de clés à l'échelle de l'organisation.",
|
||||
"coming_soon": "Bientôt disponible",
|
||||
"mcp": {
|
||||
"architecture": {
|
||||
"title": "Architecture",
|
||||
"description": "Le client MCP et l'API ClassroomIO sont des limites de confiance distinctes. La clé d'automatisation à l'échelle de l'organisation authentifie le client et l'API reste la source de vérité pour la validation et la persistance."
|
||||
},
|
||||
"flows": {
|
||||
"title": "Flux typiques",
|
||||
"description": "L'agent possède le raisonnement et la structure. ClassroomIO n'accepte que les charges utiles de cours normalisées, enregistre les brouillons et publie sur demande.",
|
||||
"prompt_title": "Invite à rédiger",
|
||||
"prompt_description": "L'utilisateur demande un cours, l'agent le structure, puis `create_course_draft` l'enregistre dans ClassroomIO avant toute étape de publication.",
|
||||
"pdf_title": "PDF à rédiger",
|
||||
"pdf_description": "L'agent lit le PDF en externe, le restructure en sections et leçons, puis envoie le brouillon normalisé dans ClassroomIO.",
|
||||
"review_title": "Revue à publier",
|
||||
"review_description": "L'utilisateur demande des modifications, l'agent met à jour le brouillon et la publication n'a lieu qu'après approbation explicite."
|
||||
},
|
||||
"setup": {
|
||||
"title": "Configuration du client",
|
||||
"description": "Utilisez une clé à l’échelle de l’organisation par intégration. Si une clé est alternée ou révoquée, le client cesse de fonctionner jusqu'à ce que le nouveau secret soit configuré.",
|
||||
"claude_code_description": "Enregistre `@cio/mcp` auprès de Claude Code à l'aide de la clé générée à l'échelle de l'organisation.",
|
||||
"claude_code_description": "Enregistre `@classroomio/mcp` auprès de Claude Code à l'aide de la clé générée à l'échelle de l'organisation.",
|
||||
"codex_description": "Ajoute le serveur ClassroomIO MCP au Codex avec la même clé de portée organisationnelle.",
|
||||
"cursor_description": "Collez-le dans `~/.cursor/mcp.json` ou dans la configuration MCP de votre espace de travail."
|
||||
},
|
||||
"keys": {
|
||||
"title": "Clés MCP",
|
||||
"description": "Ces clés authentifient l'organisation actuelle lorsqu'un client MCP appelle ClassroomIO via `@cio/mcp`.",
|
||||
"description": "Ces clés authentifient l'organisation actuelle lorsqu'un client MCP appelle ClassroomIO via `@classroomio/mcp`.",
|
||||
"generate": "Générer une clé MCP",
|
||||
"scope_badge": "Brouillon créer/lire/mettre à jour/publier",
|
||||
"secret_once": "Ce secret est montré une fois. Copiez-le dans votre client MCP maintenant."
|
||||
}
|
||||
},
|
||||
@@ -2530,10 +2515,6 @@
|
||||
"permissions": {
|
||||
"disabled_title": "Accès en lecture seule",
|
||||
"disabled_description": "Seuls les administrateurs de l'organisation peuvent créer, alterner ou révoquer des clés d'automatisation. Les enseignants peuvent consulter les instructions de configuration ici."
|
||||
},
|
||||
"overview": {
|
||||
"title": "Accès à l'automatisation",
|
||||
"description": "Créez des clés d'automatisation à l'échelle de l'organisation, connectez les clients pris en charge et conservez tous les accès aux machines liés à cette organisation."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2474,32 +2474,17 @@
|
||||
"subtitle": "एक ही ऑर्ग-स्कोप्ड कुंजी सिस्टम पर जैपियर और सार्वजनिक एपीआई के साथ अब एमसीपी क्लाइंट को कनेक्ट करें।",
|
||||
"coming_soon": "जल्द आ रहा है",
|
||||
"mcp": {
|
||||
"architecture": {
|
||||
"title": "वास्तुकला",
|
||||
"description": "एमसीपी क्लाइंट और क्लासरूमआईओ एपीआई अलग-अलग ट्रस्ट सीमाएँ हैं। ऑर्ग-स्कोप्ड ऑटोमेशन कुंजी क्लाइंट को प्रमाणित करती है, और एपीआई सत्यापन और दृढ़ता के लिए सत्य का स्रोत बना रहता है।"
|
||||
},
|
||||
"flows": {
|
||||
"title": "विशिष्ट प्रवाह",
|
||||
"description": "एजेंट के पास तर्क और संरचना होती है। क्लासरूमआईओ केवल सामान्यीकृत पाठ्यक्रम पेलोड स्वीकार करता है, ड्राफ्ट सहेजता है, और अनुरोध पर प्रकाशित करता है।",
|
||||
"prompt_title": "मसौदा तैयार करने के लिए संकेत दें",
|
||||
"prompt_description": "उपयोगकर्ता एक पाठ्यक्रम के लिए पूछता है, एजेंट इसकी संरचना करता है, फिर `create_course_draft` किसी भी प्रकाशन चरण से पहले इसे ClassroomIO में सहेजता है।",
|
||||
"pdf_title": "ड्राफ्ट करने के लिए पीडीएफ",
|
||||
"pdf_description": "एजेंट पीडीएफ को बाहरी रूप से पढ़ता है, इसे अनुभागों और पाठों में पुनर्गठित करता है, फिर सामान्यीकृत ड्राफ्ट को क्लासरूमआईओ में भेजता है।",
|
||||
"review_title": "प्रकाशित करने हेतु समीक्षा करें",
|
||||
"review_description": "उपयोगकर्ता परिवर्तन मांगता है, एजेंट ड्राफ्ट को अपडेट करता है, और प्रकाशन केवल स्पष्ट अनुमोदन के बाद ही होता है।"
|
||||
},
|
||||
"setup": {
|
||||
"title": "क्लाइंट सेटअप",
|
||||
"description": "प्रति एकीकरण एक ऑर्ग-स्कोप्ड कुंजी का उपयोग करें। यदि कोई कुंजी घुमाई जाती है या निरस्त की जाती है, तो क्लाइंट नया रहस्य कॉन्फ़िगर होने तक काम करना बंद कर देता है।",
|
||||
"claude_code_description": "उत्पन्न ऑर्ग-स्कोप्ड कुंजी का उपयोग करके क्लाउड कोड के साथ `@cio/mcp` को पंजीकृत करता है।",
|
||||
"claude_code_description": "उत्पन्न ऑर्ग-स्कोप्ड कुंजी का उपयोग करके क्लाउड कोड के साथ `@classroomio/mcp` को पंजीकृत करता है।",
|
||||
"codex_description": "समान ऑर्ग-स्कोप्ड कुंजी के साथ क्लासरूमआईओ एमसीपी सर्वर को कोडेक्स में जोड़ता है।",
|
||||
"cursor_description": "इसे `~/.cursor/mcp.json` या अपने कार्यक्षेत्र MCP कॉन्फ़िगरेशन में पेस्ट करें।"
|
||||
},
|
||||
"keys": {
|
||||
"title": "एमसीपी कुंजी",
|
||||
"description": "जब कोई MCP क्लाइंट `@cio/mcp` के माध्यम से ClassroomIO को कॉल करता है तो ये कुंजियाँ वर्तमान संगठन को प्रमाणित करती हैं।",
|
||||
"description": "जब कोई MCP क्लाइंट `@classroomio/mcp` के माध्यम से ClassroomIO को कॉल करता है तो ये कुंजियाँ वर्तमान संगठन को प्रमाणित करती हैं।",
|
||||
"generate": "MCP कुंजी जनरेट करें",
|
||||
"scope_badge": "ड्राफ्ट बनाएं/पढ़ें/अद्यतन करें/प्रकाशित करें",
|
||||
"secret_once": "यह रहस्य एक बार दिखाया गया है। अब इसे अपने MCP क्लाइंट में कॉपी करें।"
|
||||
}
|
||||
},
|
||||
@@ -2530,10 +2515,6 @@
|
||||
"permissions": {
|
||||
"disabled_title": "केवल पढ़ने योग्य पहुंच",
|
||||
"disabled_description": "केवल संगठन व्यवस्थापक ही स्वचालन कुंजियाँ बना सकते हैं, घुमा सकते हैं या निरस्त कर सकते हैं। शिक्षक यहां सेटअप निर्देशों की समीक्षा कर सकते हैं।"
|
||||
},
|
||||
"overview": {
|
||||
"title": "स्वचालन पहुंच",
|
||||
"description": "ऑर्ग-स्कोप्ड ऑटोमेशन कुंजियाँ बनाएँ, समर्थित क्लाइंट्स को कनेक्ट करें, और सभी मशीन एक्सेस को इस संगठन से जोड़े रखें।"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2474,32 +2474,17 @@
|
||||
"subtitle": "Połącz teraz klientów MCP, korzystając z Zapiera i publicznego interfejsu API w tym samym systemie kluczy o zasięgu organizacji.",
|
||||
"coming_soon": "Już wkrótce",
|
||||
"mcp": {
|
||||
"architecture": {
|
||||
"title": "Architektura",
|
||||
"description": "Klient MCP i interfejs API ClassroomIO stanowią oddzielne granice zaufania. Klucz automatyzacji o zasięgu organizacji uwierzytelnia klienta, a interfejs API pozostaje źródłem prawdy w celu sprawdzenia poprawności i trwałości."
|
||||
},
|
||||
"flows": {
|
||||
"title": "Typowe przepływy",
|
||||
"description": "Agent jest właścicielem rozumowania i struktury. ClassroomIO akceptuje tylko znormalizowane ładunki kursów, zapisuje wersje robocze i publikuje na żądanie.",
|
||||
"prompt_title": "Monituj o wersję roboczą",
|
||||
"prompt_description": "Użytkownik prosi o kurs, agent tworzy go, a następnie „create_course_draft” zapisuje go w ClassroomIO przed jakimkolwiek etapem publikacji.",
|
||||
"pdf_title": "PDF do wersji roboczej",
|
||||
"pdf_description": "Agent czyta plik PDF zewnętrznie, dzieli go na sekcje i lekcje, a następnie wysyła znormalizowaną wersję roboczą do ClassroomIO.",
|
||||
"review_title": "Recenzja do opublikowania",
|
||||
"review_description": "Użytkownik prosi o zmiany, agent aktualizuje wersję roboczą, a publikacja następuje dopiero po wyraźnej zgodzie."
|
||||
},
|
||||
"setup": {
|
||||
"title": "Konfiguracja klienta",
|
||||
"description": "Użyj jednego klucza o zasięgu organizacji na integrację. Jeśli klucz zostanie obrócony lub unieważniony, klient przestaje działać do czasu skonfigurowania nowego klucza tajnego.",
|
||||
"claude_code_description": "Rejestruje `@cio/mcp` w Claude Code przy użyciu wygenerowanego klucza o zasięgu organizacji.",
|
||||
"claude_code_description": "Rejestruje `@classroomio/mcp` w Claude Code przy użyciu wygenerowanego klucza o zasięgu organizacji.",
|
||||
"codex_description": "Dodaje serwer ClassroomIO MCP do Codexu z tym samym kluczem o zasięgu organizacji.",
|
||||
"cursor_description": "Wklej to do `~/.cursor/mcp.json` lub konfiguracji MCP w obszarze roboczym."
|
||||
},
|
||||
"keys": {
|
||||
"title": "Klucze MCP",
|
||||
"description": "Te klucze uwierzytelniają bieżącą organizację, gdy klient MCP wywołuje ClassroomIO poprzez `@cio/mcp`.",
|
||||
"description": "Te klucze uwierzytelniają bieżącą organizację, gdy klient MCP wywołuje ClassroomIO poprzez `@classroomio/mcp`.",
|
||||
"generate": "Wygeneruj klucz MCP",
|
||||
"scope_badge": "Utwórz/odczytaj/aktualizuj/publikuj wersję roboczą",
|
||||
"secret_once": "Sekret ten zostaje pokazany raz. Skopiuj go teraz do swojego klienta MCP."
|
||||
}
|
||||
},
|
||||
@@ -2530,10 +2515,6 @@
|
||||
"permissions": {
|
||||
"disabled_title": "Dostęp tylko do odczytu",
|
||||
"disabled_description": "Tylko administratorzy organizacji mogą tworzyć, obracać i unieważniać klucze automatyzacji. Nauczyciele mogą zapoznać się z instrukcjami konfiguracji tutaj."
|
||||
},
|
||||
"overview": {
|
||||
"title": "Dostęp do automatyki",
|
||||
"description": "Twórz klucze automatyzacji na poziomie organizacji, łącz obsługiwanych klientów i powiązuj cały dostęp do maszyn z tą organizacją."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2474,32 +2474,17 @@
|
||||
"subtitle": "Conecte clientes MCP agora, com o Zapier e a API pública seguindo o mesmo sistema de chaves com escopo organizacional.",
|
||||
"coming_soon": "Em breve",
|
||||
"mcp": {
|
||||
"architecture": {
|
||||
"title": "Arquitetura",
|
||||
"description": "O cliente MCP e a API ClassroomIO são limites de confiança separados. A chave de automação no escopo organizacional autentica o cliente, e a API continua sendo a fonte da verdade para validação e persistência."
|
||||
},
|
||||
"flows": {
|
||||
"title": "Fluxos típicos",
|
||||
"description": "O agente possui raciocínio e estrutura. ClassroomIO aceita apenas cargas de curso normalizadas, salva rascunhos e publica mediante solicitação.",
|
||||
"prompt_title": "Solicitar rascunho",
|
||||
"prompt_description": "O usuário solicita um curso, o agente o estrutura e, em seguida, `create_course_draft` o salva no ClassroomIO antes de qualquer etapa de publicação.",
|
||||
"pdf_title": "PDF para rascunho",
|
||||
"pdf_description": "O agente lê o PDF externamente, reestrutura-o em seções e lições e, em seguida, envia o rascunho normalizado para o ClassroomIO.",
|
||||
"review_title": "Revise para publicar",
|
||||
"review_description": "O usuário solicita alterações, o agente atualiza o rascunho e a publicação só acontece após aprovação explícita."
|
||||
},
|
||||
"setup": {
|
||||
"title": "Configuração do cliente",
|
||||
"description": "Use uma chave com escopo organizacional por integração. Se uma chave for rotacionada ou revogada, o cliente para de funcionar até que o novo segredo seja configurado.",
|
||||
"claude_code_description": "Registra `@cio/mcp` com Claude Code usando a chave gerada com escopo organizacional.",
|
||||
"claude_code_description": "Registra `@classroomio/mcp` com Claude Code usando a chave gerada com escopo organizacional.",
|
||||
"codex_description": "Adiciona o servidor ClassroomIO MCP ao Codex com a mesma chave com escopo organizacional.",
|
||||
"cursor_description": "Cole isso em `~/.cursor/mcp.json` ou na configuração do MCP do seu espaço de trabalho."
|
||||
},
|
||||
"keys": {
|
||||
"title": "Chaves MCP",
|
||||
"description": "Essas chaves autenticam a organização atual quando um cliente MCP chama ClassroomIO por meio de `@cio/mcp`.",
|
||||
"description": "Essas chaves autenticam a organização atual quando um cliente MCP chama ClassroomIO por meio de `@classroomio/mcp`.",
|
||||
"generate": "Gerar chave MCP",
|
||||
"scope_badge": "Rascunho criar/ler/atualizar/publicar",
|
||||
"secret_once": "Este segredo é mostrado uma vez. Copie-o para o seu cliente MCP agora."
|
||||
}
|
||||
},
|
||||
@@ -2530,10 +2515,6 @@
|
||||
"permissions": {
|
||||
"disabled_title": "Acesso somente leitura",
|
||||
"disabled_description": "Somente administradores da organização podem criar, alternar ou revogar chaves de automação. Os professores podem revisar as instruções de configuração aqui."
|
||||
},
|
||||
"overview": {
|
||||
"title": "Acesso de automação",
|
||||
"description": "Crie chaves de automação no escopo da organização, conecte clientes compatíveis e mantenha todo o acesso às máquinas vinculado a esta organização."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2474,32 +2474,17 @@
|
||||
"subtitle": "Подключите клиентов MCP прямо сейчас, используя Zapier и общедоступный API в одной и той же системе ключей на уровне организации.",
|
||||
"coming_soon": "Скоро",
|
||||
"mcp": {
|
||||
"architecture": {
|
||||
"title": "Архитектура",
|
||||
"description": "Клиент MCP и API ClassroomIO представляют собой отдельные границы доверия. Ключ автоматизации на уровне организации аутентифицирует клиента, а API остается источником достоверных данных для проверки и сохранения."
|
||||
},
|
||||
"flows": {
|
||||
"title": "Типичные потоки",
|
||||
"description": "Агент владеет рассуждениями и структурой. ClassroomIO принимает только нормализованные полезные нагрузки курса, сохраняет черновики и публикует по запросу.",
|
||||
"prompt_title": "Подскажите черновик",
|
||||
"prompt_description": "Пользователь запрашивает курс, агент структурирует его, затем `create_course_draft` сохраняет его в ClassroomIO перед любым шагом публикации.",
|
||||
"pdf_title": "PDF для черновика",
|
||||
"pdf_description": "Агент читает PDF-файл извне, реструктурирует его на разделы и уроки, а затем отправляет нормализованный черновик в ClassroomIO.",
|
||||
"review_title": "Отзыв для публикации",
|
||||
"review_description": "Пользователь запрашивает изменения, агент обновляет черновик, а публикация происходит только после явного одобрения."
|
||||
},
|
||||
"setup": {
|
||||
"title": "Настройка клиента",
|
||||
"description": "Используйте один ключ на уровне организации для каждой интеграции. Если ключ заменен или отозван, клиент перестает работать до тех пор, пока не будет настроен новый секрет.",
|
||||
"claude_code_description": "Регистрирует `@cio/mcp` в Claude Code, используя сгенерированный ключ на уровне организации.",
|
||||
"claude_code_description": "Регистрирует `@classroomio/mcp` в Claude Code, используя сгенерированный ключ на уровне организации.",
|
||||
"codex_description": "Добавляет сервер ClassroomIO MCP в Кодекс с тем же ключом на уровне организации.",
|
||||
"cursor_description": "Вставьте это в `~/.cursor/mcp.json` или в конфигурацию MCP вашей рабочей области."
|
||||
},
|
||||
"keys": {
|
||||
"title": "Ключи MCP",
|
||||
"description": "Эти ключи аутентифицируют текущую организацию, когда клиент MCP вызывает ClassroomIO через @cio/mcp.",
|
||||
"description": "Эти ключи аутентифицируют текущую организацию, когда клиент MCP вызывает ClassroomIO через @classroomio/mcp.",
|
||||
"generate": "Создать ключ MCP",
|
||||
"scope_badge": "Черновик создать/прочитать/обновить/опубликовать",
|
||||
"secret_once": "Этот секрет раскрывается один раз. Скопируйте его в свой клиент MCP сейчас."
|
||||
}
|
||||
},
|
||||
@@ -2530,10 +2515,6 @@
|
||||
"permissions": {
|
||||
"disabled_title": "Доступ только для чтения",
|
||||
"disabled_description": "Только администраторы организации могут создавать, менять или отзывать ключи автоматизации. Учителя могут ознакомиться с инструкциями по настройке здесь."
|
||||
},
|
||||
"overview": {
|
||||
"title": "Автоматизация доступа",
|
||||
"description": "Создавайте ключи автоматизации на уровне организации, подключайте поддерживаемых клиентов и сохраняйте привязку всего доступа к машинам к этой организации."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2474,32 +2474,17 @@
|
||||
"subtitle": "Kết nối các máy khách MCP ngay bây giờ, với Zapier và API công khai trên cùng một hệ thống khóa trong phạm vi tổ chức.",
|
||||
"coming_soon": "Sắp có",
|
||||
"mcp": {
|
||||
"architecture": {
|
||||
"title": "Kiến trúc",
|
||||
"description": "Ứng dụng khách MCP và API ClassroomIO là các ranh giới tin cậy riêng biệt. Khóa tự động hóa trong phạm vi tổ chức sẽ xác thực ứng dụng khách và API vẫn là nguồn thông tin chính xác để xác thực và duy trì."
|
||||
},
|
||||
"flows": {
|
||||
"title": "Dòng chảy điển hình",
|
||||
"description": "Tác nhân sở hữu lý luận và cấu trúc. ClassroomIO chỉ chấp nhận tải trọng khóa học được chuẩn hóa, lưu bản nháp và xuất bản theo yêu cầu.",
|
||||
"prompt_title": "Nhắc soạn thảo",
|
||||
"prompt_description": "Người dùng yêu cầu một khóa học, nhân viên sẽ cấu trúc khóa học đó, sau đó `create_course_draft` lưu nó vào ClassroomIO trước bất kỳ bước xuất bản nào.",
|
||||
"pdf_title": "PDF thành bản nháp",
|
||||
"pdf_description": "Nhân viên đọc bản PDF bên ngoài, cấu trúc lại thành các phần và bài học, sau đó gửi bản nháp đã chuẩn hóa vào ClassroomIO.",
|
||||
"review_title": "Xem xét để xuất bản",
|
||||
"review_description": "Người dùng yêu cầu thay đổi, người đại diện cập nhật bản nháp và việc xuất bản chỉ diễn ra sau khi được phê duyệt rõ ràng."
|
||||
},
|
||||
"setup": {
|
||||
"title": "Thiết lập máy khách",
|
||||
"description": "Sử dụng một khóa trong phạm vi tổ chức cho mỗi lần tích hợp. Nếu một khóa bị xoay hoặc bị thu hồi, máy khách sẽ ngừng hoạt động cho đến khi bí mật mới được định cấu hình.",
|
||||
"claude_code_description": "Đăng ký `@cio/mcp` với Mã Claude bằng cách sử dụng khóa trong phạm vi tổ chức được tạo.",
|
||||
"claude_code_description": "Đăng ký `@classroomio/mcp` với Mã Claude bằng cách sử dụng khóa trong phạm vi tổ chức được tạo.",
|
||||
"codex_description": "Thêm máy chủ ClassroomIO MCP vào Codex bằng cùng một khóa trong phạm vi tổ chức.",
|
||||
"cursor_description": "Dán cái này vào `~/.cursor/mcp.json` hoặc cấu hình MCP không gian làm việc của bạn."
|
||||
},
|
||||
"keys": {
|
||||
"title": "Khóa MCP",
|
||||
"description": "Các khóa này xác thực tổ chức hiện tại khi máy khách MCP gọi ClassroomIO thông qua `@cio/mcp`.",
|
||||
"description": "Các khóa này xác thực tổ chức hiện tại khi máy khách MCP gọi ClassroomIO thông qua `@classroomio/mcp`.",
|
||||
"generate": "Tạo khóa MCP",
|
||||
"scope_badge": "Bản nháp tạo/đọc/cập nhật/xuất bản",
|
||||
"secret_once": "Bí mật này được hiển thị một lần. Sao chép nó vào ứng dụng khách MCP của bạn ngay bây giờ."
|
||||
}
|
||||
},
|
||||
@@ -2530,10 +2515,6 @@
|
||||
"permissions": {
|
||||
"disabled_title": "Quyền truy cập chỉ đọc",
|
||||
"disabled_description": "Chỉ quản trị viên tổ chức mới có thể tạo, xoay hoặc thu hồi khóa tự động hóa. Thầy cô có thể xem lại hướng dẫn thiết lập tại đây."
|
||||
},
|
||||
"overview": {
|
||||
"title": "Truy cập tự động hóa",
|
||||
"description": "Tạo khóa tự động hóa trong phạm vi tổ chức, kết nối các máy khách được hỗ trợ và giữ tất cả quyền truy cập của máy được gắn chặt với tổ chức này."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import ShieldIcon from '@lucide/svelte/icons/shield';
|
||||
|
||||
const emailFromUrl = page.url.searchParams.get('email') ?? '';
|
||||
const isEmailPrefilled = !!emailFromUrl;
|
||||
|
||||
let fields = $state(Object.assign({}, LOGIN_FIELDS, emailFromUrl ? { email: emailFromUrl } : {}));
|
||||
|
||||
@@ -192,7 +193,8 @@
|
||||
bind:value={fields.email}
|
||||
oninput={handleEmailChange}
|
||||
placeholder="[email protected]"
|
||||
disabled={loading}
|
||||
disabled={loading || isEmailPrefilled}
|
||||
readonly={isEmailPrefilled}
|
||||
autofocus
|
||||
aria-invalid={errors.email ? 'true' : undefined}
|
||||
autocomplete="username"
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
let { data } = $props();
|
||||
const emailFromUrl = page.url.searchParams.get('email') ?? '';
|
||||
const isEmailPrefilled = !!emailFromUrl;
|
||||
let fields = $state(Object.assign({}, SIGNUP_FIELDS, emailFromUrl ? { email: emailFromUrl } : {}));
|
||||
let loading = $state(false);
|
||||
let errors: {
|
||||
@@ -160,7 +161,8 @@
|
||||
type="email"
|
||||
bind:value={fields.email}
|
||||
placeholder="[email protected]"
|
||||
disabled={loading}
|
||||
disabled={loading || isEmailPrefilled}
|
||||
readonly={isEmailPrefilled}
|
||||
autofocus
|
||||
aria-invalid={errors.email ? 'true' : undefined}
|
||||
autocomplete="email"
|
||||
|
||||
@@ -45,6 +45,8 @@ export const load = async ({ params, url }) => {
|
||||
course,
|
||||
currentOrg,
|
||||
invite: data.invite,
|
||||
inviteEmail: data.inviteContext?.recipientEmail ?? null,
|
||||
inviteEmailExists: data.inviteContext?.recipientExists ?? false,
|
||||
requiresPaymentOrInvite: false,
|
||||
token: inviteToken
|
||||
};
|
||||
|
||||
@@ -64,7 +64,12 @@
|
||||
const redirectUrl = `${redirectPath}${redirectSearch}`;
|
||||
|
||||
if (!$profile.id || !$profile.email) {
|
||||
goto(resolve(`/signup?redirect=${encodeURIComponent(redirectUrl)}`, {}));
|
||||
const inviteEmail = data.inviteEmail ?? '';
|
||||
const target = data.inviteEmailExists ? '/login' : '/signup';
|
||||
const params = new URLSearchParams({ redirect: redirectUrl });
|
||||
if (inviteEmail) params.set('email', inviteEmail);
|
||||
|
||||
goto(resolve(`${target}?${params.toString()}`, {}));
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<script>
|
||||
import { AutomationPage } from '$features/automation/pages';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{t.get('automation.title')} - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('automation.title')}</Page.Title>
|
||||
<Page.Subtitle>{$t('automation.subtitle')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<AutomationPage />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
@@ -5,6 +5,7 @@ node_modules
|
||||
.vercel
|
||||
.output
|
||||
.nitro
|
||||
.wrangler
|
||||
/build/
|
||||
/api/
|
||||
/server/build
|
||||
@@ -14,6 +15,7 @@ node_modules
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
.tanstack
|
||||
.dev.vars
|
||||
|
||||
src/routeTree.gen.ts
|
||||
.source
|
||||
.source
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineNitroConfig } from 'nitro/config';
|
||||
|
||||
export default defineNitroConfig({
|
||||
compatibilityDate: '2026-03-13',
|
||||
preset: 'cloudflare_module',
|
||||
cloudflare: {
|
||||
deployConfig: true,
|
||||
nodeCompat: true
|
||||
}
|
||||
});
|
||||
@@ -4,7 +4,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"start": "node .output/server/index.mjs",
|
||||
"preview": "wrangler dev --port 4174",
|
||||
"start": "wrangler dev --port 3001",
|
||||
"types:check": "fumadocs-mdx && tsc --noEmit",
|
||||
"postinstall": "fumadocs-mdx"
|
||||
},
|
||||
@@ -32,6 +33,7 @@
|
||||
"nitro": "3.0.1-alpha.1",
|
||||
"tailwindcss": "^4.1.16",
|
||||
"typescript": "^5.9.3",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"wrangler": "^4.21.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 5174",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "node build",
|
||||
"preview": "wrangler dev --port 4173",
|
||||
"start": "wrangler dev --port 3000",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
|
||||
"format": "prettier --write .",
|
||||
@@ -14,8 +14,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cio/tsconfig": "workspace:*",
|
||||
"@sveltejs/adapter-node": "^2.0.0",
|
||||
"@sveltejs/adapter-vercel": "^4.0.0",
|
||||
"@sveltejs/adapter-cloudflare": "^7.0.0",
|
||||
"@sveltejs/kit": "^2.5.27",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
@@ -25,7 +24,8 @@
|
||||
"svelte": "^5.0.0",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.4.4"
|
||||
"vite": "^5.4.4",
|
||||
"wrangler": "^4.21.2"
|
||||
},
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -29,8 +29,8 @@ preload="auto"
|
||||
autoPictureInPicture
|
||||
class="rounded-md"
|
||||
src="https://assets.cdn.clsrio.com/blog/organisation-student-analytics.mp4"
|
||||
>
|
||||
</video>
|
||||
|
||||
> </video>
|
||||
|
||||
## Course Student Analytics
|
||||
|
||||
@@ -52,8 +52,8 @@ autoPictureInPicture
|
||||
class="rounded-md"
|
||||
src="https://assets.cdn.clsrio.com/blog/course-students-analytics.mp4"
|
||||
controls
|
||||
>
|
||||
</video>
|
||||
|
||||
> </video>
|
||||
|
||||
## What's Next?
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { escapeSvelte, mdsvex } from 'mdsvex';
|
||||
|
||||
import adapterNode from '@sveltejs/adapter-node';
|
||||
import adapterVercel from '@sveltejs/adapter-vercel';
|
||||
import adapter from '@sveltejs/adapter-cloudflare';
|
||||
import { dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'path';
|
||||
@@ -11,8 +10,6 @@ import remarkUnwrapImages from 'remark-unwrap-images';
|
||||
import shiki from 'shiki';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
const useNodeAdapter = process.env.VERCEL !== '1' && process.env.VERCEL !== 'true';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config}*/
|
||||
|
||||
/** @type {import('mdsvex').MdsvexOptions} */
|
||||
@@ -34,7 +31,7 @@ const mdsvexOptions = {
|
||||
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: useNodeAdapter ? adapterNode() : adapterVercel(),
|
||||
adapter: adapter(),
|
||||
alias: {
|
||||
$lib: path.resolve('./src/lib'),
|
||||
'$src/tools': path.resolve('./node_modules/@cio/ui/src/tools/index.ts'),
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "classroomio-website",
|
||||
"main": ".svelte-kit/cloudflare/_worker.js",
|
||||
"compatibility_date": "2026-03-13",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"assets": {
|
||||
"binding": "ASSETS",
|
||||
"directory": ".svelte-kit/cloudflare"
|
||||
}
|
||||
}
|
||||
@@ -26,8 +26,5 @@ export const markUserAndProfileEmailVerified = async (userId: string, dbClient:
|
||||
const verifiedAt = new Date().toISOString();
|
||||
|
||||
await dbClient.update(schema.user).set({ emailVerified: true }).where(eq(schema.user.id, userId));
|
||||
await dbClient
|
||||
.update(schema.profile)
|
||||
.set({ isEmailVerified: true, verifiedAt })
|
||||
.where(eq(schema.profile.id, userId));
|
||||
await dbClient.update(schema.profile).set({ isEmailVerified: true, verifiedAt }).where(eq(schema.profile.id, userId));
|
||||
};
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@db/drizzle';
|
||||
import type { TCourseImportDraft, TNewCourseImportDraft } from '@db/types';
|
||||
|
||||
export async function createCourseImportDraft(data: TNewCourseImportDraft): Promise<TCourseImportDraft> {
|
||||
try {
|
||||
const [draft] = await db.insert(schema.courseImportDraft).values(data).returning();
|
||||
|
||||
if (!draft) {
|
||||
throw new Error('Failed to create course import draft');
|
||||
}
|
||||
|
||||
return draft;
|
||||
} catch (error) {
|
||||
console.error('createCourseImportDraft error:', error);
|
||||
throw new Error('Failed to create course import draft');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCourseImportDraftById(orgId: string, draftId: string): Promise<TCourseImportDraft | null> {
|
||||
try {
|
||||
const [draft] = await db
|
||||
.select()
|
||||
.from(schema.courseImportDraft)
|
||||
.where(and(eq(schema.courseImportDraft.organizationId, orgId), eq(schema.courseImportDraft.id, draftId)))
|
||||
.limit(1);
|
||||
|
||||
return draft || null;
|
||||
} catch (error) {
|
||||
console.error('getCourseImportDraftById error:', error);
|
||||
throw new Error('Failed to fetch course import draft');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCourseImportDraftByIdempotencyKey(
|
||||
orgId: string,
|
||||
idempotencyKey: string
|
||||
): Promise<TCourseImportDraft | null> {
|
||||
try {
|
||||
const [draft] = await db
|
||||
.select()
|
||||
.from(schema.courseImportDraft)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.courseImportDraft.organizationId, orgId),
|
||||
eq(schema.courseImportDraft.idempotencyKey, idempotencyKey)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return draft || null;
|
||||
} catch (error) {
|
||||
console.error('getCourseImportDraftByIdempotencyKey error:', error);
|
||||
throw new Error('Failed to fetch course import draft by idempotency key');
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCourseImportDraft(
|
||||
orgId: string,
|
||||
draftId: string,
|
||||
data: Partial<TCourseImportDraft>
|
||||
): Promise<TCourseImportDraft | null> {
|
||||
try {
|
||||
const [draft] = await db
|
||||
.update(schema.courseImportDraft)
|
||||
.set({
|
||||
...data,
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
.where(and(eq(schema.courseImportDraft.organizationId, orgId), eq(schema.courseImportDraft.id, draftId)))
|
||||
.returning();
|
||||
|
||||
return draft || null;
|
||||
} catch (error) {
|
||||
console.error('updateCourseImportDraft error:', error);
|
||||
throw new Error('Failed to update course import draft');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './course-import';
|
||||
@@ -11,3 +11,4 @@ export * from './mark';
|
||||
export * from './newsfeed';
|
||||
export * from './submission';
|
||||
export * from './tag';
|
||||
export * from './course-import';
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import { and, desc, eq, isNull } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@db/drizzle';
|
||||
import type { TNewOrganizationApiKey, TOrganizationApiKey, TOrganizationApiKeyType } from '@db/types';
|
||||
|
||||
export const createOrganizationApiKey = async (data: TNewOrganizationApiKey): Promise<TOrganizationApiKey> => {
|
||||
try {
|
||||
const [row] = await db.insert(schema.organizationApiKey).values(data).returning();
|
||||
|
||||
if (!row) {
|
||||
throw new Error('Failed to create organization API key');
|
||||
}
|
||||
|
||||
return row;
|
||||
} catch (error) {
|
||||
console.error('createOrganizationApiKey error:', error);
|
||||
throw new Error('Failed to create organization API key');
|
||||
}
|
||||
};
|
||||
|
||||
export const listOrganizationApiKeys = async (
|
||||
organizationId: string,
|
||||
type?: TOrganizationApiKeyType
|
||||
): Promise<TOrganizationApiKey[]> => {
|
||||
try {
|
||||
const conditions = [eq(schema.organizationApiKey.organizationId, organizationId)];
|
||||
|
||||
if (type) {
|
||||
conditions.push(eq(schema.organizationApiKey.type, type));
|
||||
}
|
||||
|
||||
return db
|
||||
.select()
|
||||
.from(schema.organizationApiKey)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(schema.organizationApiKey.createdAt));
|
||||
} catch (error) {
|
||||
console.error('listOrganizationApiKeys error:', error);
|
||||
throw new Error('Failed to list organization API keys');
|
||||
}
|
||||
};
|
||||
|
||||
export const getOrganizationApiKeyById = async (
|
||||
organizationId: string,
|
||||
keyId: string
|
||||
): Promise<TOrganizationApiKey | null> => {
|
||||
try {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(schema.organizationApiKey)
|
||||
.where(and(eq(schema.organizationApiKey.organizationId, organizationId), eq(schema.organizationApiKey.id, keyId)))
|
||||
.limit(1);
|
||||
|
||||
return row || null;
|
||||
} catch (error) {
|
||||
console.error('getOrganizationApiKeyById error:', error);
|
||||
throw new Error('Failed to get organization API key by id');
|
||||
}
|
||||
};
|
||||
|
||||
export const getActiveOrganizationApiKeyByHash = async (secretHash: string): Promise<TOrganizationApiKey | null> => {
|
||||
try {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(schema.organizationApiKey)
|
||||
.where(and(eq(schema.organizationApiKey.secretHash, secretHash), isNull(schema.organizationApiKey.revokedAt)))
|
||||
.limit(1);
|
||||
|
||||
return row || null;
|
||||
} catch (error) {
|
||||
console.error('getActiveOrganizationApiKeyByHash error:', error);
|
||||
throw new Error('Failed to get organization API key by hash');
|
||||
}
|
||||
};
|
||||
|
||||
export const updateOrganizationApiKey = async (
|
||||
organizationId: string,
|
||||
keyId: string,
|
||||
data: Partial<TOrganizationApiKey>
|
||||
): Promise<TOrganizationApiKey | null> => {
|
||||
try {
|
||||
const [row] = await db
|
||||
.update(schema.organizationApiKey)
|
||||
.set({
|
||||
...data,
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
.where(and(eq(schema.organizationApiKey.organizationId, organizationId), eq(schema.organizationApiKey.id, keyId)))
|
||||
.returning();
|
||||
|
||||
return row || null;
|
||||
} catch (error) {
|
||||
console.error('updateOrganizationApiKey error:', error);
|
||||
throw new Error('Failed to update organization API key');
|
||||
}
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './organization';
|
||||
export * from './quiz';
|
||||
export * from './invite';
|
||||
export * from './automation-key';
|
||||
|
||||
@@ -25,6 +25,9 @@ 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 courseImportDraftStatus = pgEnum('COURSE_IMPORT_DRAFT_STATUS', ['DRAFT', 'PUBLISHED', 'ARCHIVED']);
|
||||
export const organizationApiKeyType = pgEnum('ORGANIZATION_API_KEY_TYPE', ['mcp', 'api', 'zapier']);
|
||||
export const courseInviteEventType = pgEnum('COURSE_INVITE_EVENT_TYPE', [
|
||||
'CREATED',
|
||||
'REVOKED',
|
||||
@@ -1996,3 +1999,77 @@ export const organizationTokenAuth = pgTable(
|
||||
},
|
||||
(table) => [index('idx_organization_token_auth_org_id').on(table.organizationId)]
|
||||
);
|
||||
|
||||
export const organizationApiKey = pgTable(
|
||||
'organization_api_key',
|
||||
{
|
||||
id: uuid('id')
|
||||
.default(sql`gen_random_uuid()`)
|
||||
.primaryKey()
|
||||
.notNull(),
|
||||
organizationId: uuid('organization_id')
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: 'cascade' }),
|
||||
createdByProfileId: uuid('created_by_profile_id')
|
||||
.notNull()
|
||||
.references(() => profile.id),
|
||||
type: organizationApiKeyType('type').notNull(),
|
||||
label: varchar('label', { length: 120 }).notNull(),
|
||||
secretPrefix: varchar('secret_prefix', { length: 32 }).notNull(),
|
||||
secretHash: text('secret_hash').notNull(),
|
||||
scopes: jsonb('scopes').default([]).notNull().$type<string[]>(),
|
||||
lastUsedAt: timestamp('last_used_at', { withTimezone: true, mode: 'string' }),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true, mode: 'string' }),
|
||||
revokedAt: timestamp('revoked_at', { withTimezone: true, mode: 'string' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' })
|
||||
.default(sql`timezone('utc'::text, now())`)
|
||||
.notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' })
|
||||
.default(sql`timezone('utc'::text, now())`)
|
||||
.notNull()
|
||||
},
|
||||
(table) => [
|
||||
index('idx_organization_api_key_org_id').on(table.organizationId),
|
||||
index('idx_organization_api_key_type').on(table.type),
|
||||
uniqueIndex('organization_api_key_secret_hash_unique').on(table.secretHash)
|
||||
]
|
||||
);
|
||||
|
||||
export const courseImportDraft = pgTable(
|
||||
'course_import_draft',
|
||||
{
|
||||
id: uuid()
|
||||
.default(sql`gen_random_uuid()`)
|
||||
.primaryKey()
|
||||
.notNull(),
|
||||
organizationId: uuid('organization_id')
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: 'cascade' }),
|
||||
createdByProfileId: uuid('created_by_profile_id')
|
||||
.notNull()
|
||||
.references(() => profile.id),
|
||||
sourceType: courseImportSourceType('source_type').notNull(),
|
||||
status: courseImportDraftStatus('status').default('DRAFT').notNull(),
|
||||
title: varchar().notNull(),
|
||||
locale: locale().default('en').notNull(),
|
||||
idempotencyKey: varchar('idempotency_key'),
|
||||
summary: jsonb().default({}).notNull().$type<Record<string, unknown>>(),
|
||||
draft: jsonb().notNull().$type<Record<string, unknown>>(),
|
||||
warnings: jsonb().default([]).notNull().$type<Array<Record<string, unknown>>>(),
|
||||
sourceArtifacts: jsonb().default([]).notNull().$type<Array<Record<string, unknown>>>(),
|
||||
publishedCourseId: uuid('published_course_id').references(() => course.id, { onDelete: 'set null' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' })
|
||||
.default(sql`timezone('utc'::text, now())`)
|
||||
.notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' })
|
||||
.default(sql`timezone('utc'::text, now())`)
|
||||
.notNull()
|
||||
},
|
||||
(table) => [
|
||||
index('idx_course_import_draft_org_id').on(table.organizationId),
|
||||
index('idx_course_import_draft_status').on(table.status),
|
||||
uniqueIndex('course_import_draft_org_idempotency_key')
|
||||
.on(table.organizationId, table.idempotencyKey)
|
||||
.where(sql`${table.idempotencyKey} IS NOT NULL`)
|
||||
]
|
||||
);
|
||||
|
||||
@@ -169,6 +169,12 @@ export type TNewOrganizationSsoConfig = typeof schema.organizationSsoConfig.$inf
|
||||
export type TOrganizationTokenAuth = typeof schema.organizationTokenAuth.$inferSelect;
|
||||
export type TNewOrganizationTokenAuth = typeof schema.organizationTokenAuth.$inferInsert;
|
||||
|
||||
export type TOrganizationApiKey = typeof schema.organizationApiKey.$inferSelect;
|
||||
export type TNewOrganizationApiKey = typeof schema.organizationApiKey.$inferInsert;
|
||||
|
||||
export type TCourseImportDraft = typeof schema.courseImportDraft.$inferSelect;
|
||||
export type TNewCourseImportDraft = typeof schema.courseImportDraft.$inferInsert;
|
||||
|
||||
export type TCourseType = (typeof schema.courseType.enumValues)[number];
|
||||
|
||||
export type TLocale = (typeof schema.locale.enumValues)[number];
|
||||
@@ -180,3 +186,9 @@ export type TCourseInviteEventType = (typeof schema.courseInviteEventType.enumVa
|
||||
export type TOrganizationInviteEventType = (typeof schema.organizationInviteEventType.enumValues)[number];
|
||||
|
||||
export type TSsoProvider = (typeof schema.organizationSsoProviderType.enumValues)[number];
|
||||
|
||||
export type TOrganizationApiKeyType = (typeof schema.organizationApiKeyType.enumValues)[number];
|
||||
|
||||
export type TCourseImportSourceType = (typeof schema.courseImportSourceType.enumValues)[number];
|
||||
|
||||
export type TCourseImportDraftStatus = (typeof schema.courseImportDraftStatus.enumValues)[number];
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
bind:this={ref}
|
||||
data-slot="field-separator"
|
||||
data-content={hasContent}
|
||||
class={cn('ui:relative ui:-my-2 ui:h-5 ui:text-sm ui:group-data-[variant=outline]/field-group:-mb-2', className)}
|
||||
class={cn('ui:relative ui:my-2 ui:h-5 ui:text-sm ui:group-data-[variant=outline]/field-group:-mb-2', className)}
|
||||
{...restProps}
|
||||
>
|
||||
<Separator class="ui:absolute ui:inset-0 ui:top-1/2" />
|
||||
|
||||
@@ -1,37 +1,22 @@
|
||||
/**
|
||||
* Server-side file type validation utilities for preventing malicious uploads
|
||||
* Specifically designed to prevent SVG XSS attacks and other security issues
|
||||
*/
|
||||
import { ALLOWED_IMAGE_TYPES } from '../validation/constants';
|
||||
|
||||
export const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'] as const;
|
||||
|
||||
/**
|
||||
* Validate file type by MIME type
|
||||
* @param file - File object to validate
|
||||
* @returns boolean indicating if file type is allowed
|
||||
*/
|
||||
export function validateImageType(file: File): boolean {
|
||||
type FileLike = {
|
||||
type: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export function validateImageType(file: FileLike): boolean {
|
||||
return ALLOWED_IMAGE_TYPES.includes(file.type as (typeof ALLOWED_IMAGE_TYPES)[number]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file extension
|
||||
* @param filename - Filename to validate
|
||||
* @returns boolean indicating if extension is allowed
|
||||
*/
|
||||
export function validateImageExtension(filename: string): boolean {
|
||||
const extension = filename.toLowerCase().substring(filename.lastIndexOf('.'));
|
||||
return ALLOWED_EXTENSIONS.includes(extension as (typeof ALLOWED_EXTENSIONS)[number]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Comprehensive file validation
|
||||
* @param file - File object to validate
|
||||
* @returns object with validation result and error message
|
||||
*/
|
||||
export function validateImageUpload(file: File): { isValid: boolean; error?: string } {
|
||||
// Check file type
|
||||
export function validateImageUpload(file: FileLike): { isValid: boolean; error?: string } {
|
||||
if (!validateImageType(file)) {
|
||||
return {
|
||||
isValid: false,
|
||||
@@ -39,7 +24,6 @@ export function validateImageUpload(file: File): { isValid: boolean; error?: str
|
||||
};
|
||||
}
|
||||
|
||||
// Check file extension (double check for security)
|
||||
if (!validateImageExtension(file.name)) {
|
||||
return {
|
||||
isValid: false,
|
||||
@@ -47,7 +31,6 @@ export function validateImageUpload(file: File): { isValid: boolean; error?: str
|
||||
};
|
||||
}
|
||||
|
||||
// Additional security checks
|
||||
if (file.name.toLowerCase().includes('.svg')) {
|
||||
return {
|
||||
isValid: false,
|
||||
@@ -58,23 +41,15 @@ export function validateImageUpload(file: File): { isValid: boolean; error?: str
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize filename to prevent directory traversal and other issues
|
||||
* @param filename - Original filename
|
||||
* @returns sanitized filename
|
||||
*/
|
||||
export function sanitizeFilename(filename: string): string {
|
||||
// Remove path traversal attempts
|
||||
let sanitized = filename.replace(/[/\\]/g, '');
|
||||
|
||||
// Remove special characters that could cause issues
|
||||
let sanitized = filename.replace(/[\/\\]/g, '');
|
||||
sanitized = sanitized.replace(/[<>:"|?*]/g, '');
|
||||
|
||||
// Limit length
|
||||
if (sanitized.length > 255) {
|
||||
const ext = sanitized.substring(sanitized.lastIndexOf('.'));
|
||||
const name = sanitized.substring(0, sanitized.lastIndexOf('.'));
|
||||
sanitized = name.substring(0, 255 - ext.length) + ext;
|
||||
const extIndex = sanitized.lastIndexOf('.');
|
||||
const ext = extIndex !== -1 ? sanitized.substring(extIndex) : '';
|
||||
const name = extIndex !== -1 ? sanitized.substring(0, extIndex) : sanitized;
|
||||
sanitized = `${name.substring(0, 255 - ext.length)}${ext}`;
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './fileValidation';
|
||||
export * from './array';
|
||||
export * from './fileValidation';
|
||||
export * from './slug';
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export type GenerateSlugOptions = {
|
||||
fallback?: string;
|
||||
appendTimestamp?: boolean;
|
||||
};
|
||||
|
||||
export function generateSlug(source: string | null | undefined, options: GenerateSlugOptions = {}): string {
|
||||
const { fallback = 'item', appendTimestamp = false } = options;
|
||||
|
||||
const base =
|
||||
(source ?? fallback)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '') || fallback;
|
||||
|
||||
return appendTimestamp ? `${base}-${Date.now()}` : base;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
import { ZCourseMetadata } from '../course/course';
|
||||
|
||||
const ZSupportedLocale = z.enum(['en', 'hi', 'fr', 'pt', 'de', 'vi', 'ru', 'es', 'pl', 'da']);
|
||||
|
||||
export const ZCourseImportWarning = z.object({
|
||||
code: z.string().min(1),
|
||||
message: z.string().min(1),
|
||||
severity: z.enum(['info', 'warning', 'error'])
|
||||
});
|
||||
export type TCourseImportWarning = z.infer<typeof ZCourseImportWarning>;
|
||||
|
||||
export const ZCourseImportSourceReference = z.object({
|
||||
type: z.enum(['prompt', 'pdf']),
|
||||
label: z.string().min(1),
|
||||
pageStart: z.number().int().min(1).optional(),
|
||||
pageEnd: z.number().int().min(1).optional()
|
||||
});
|
||||
export type TCourseImportSourceReference = z.infer<typeof ZCourseImportSourceReference>;
|
||||
|
||||
export const ZCourseImportDraftCourse = z.object({
|
||||
title: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
type: z.enum(['LIVE_CLASS', 'SELF_PACED']),
|
||||
locale: ZSupportedLocale.default('en'),
|
||||
metadata: ZCourseMetadata.optional()
|
||||
});
|
||||
export type TCourseImportDraftCourse = z.infer<typeof ZCourseImportDraftCourse>;
|
||||
|
||||
export const ZCourseImportDraftSection = z.object({
|
||||
externalId: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
order: z.number().int().min(0)
|
||||
});
|
||||
export type TCourseImportDraftSection = z.infer<typeof ZCourseImportDraftSection>;
|
||||
|
||||
export const ZCourseImportDraftLesson = z.object({
|
||||
externalId: z.string().min(1),
|
||||
sectionExternalId: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
order: z.number().int().min(0),
|
||||
isUnlocked: z.boolean().optional(),
|
||||
public: z.boolean().optional()
|
||||
});
|
||||
export type TCourseImportDraftLesson = z.infer<typeof ZCourseImportDraftLesson>;
|
||||
|
||||
export const ZCourseImportDraftLessonLanguage = z.object({
|
||||
lessonExternalId: z.string().min(1),
|
||||
locale: ZSupportedLocale,
|
||||
content: z.string().min(1)
|
||||
});
|
||||
export type TCourseImportDraftLessonLanguage = z.infer<typeof ZCourseImportDraftLessonLanguage>;
|
||||
|
||||
export const ZCourseImportDraftPayload = z
|
||||
.object({
|
||||
course: ZCourseImportDraftCourse,
|
||||
sections: z.array(ZCourseImportDraftSection).min(1),
|
||||
lessons: z.array(ZCourseImportDraftLesson).min(1),
|
||||
lessonLanguages: z.array(ZCourseImportDraftLessonLanguage).min(1),
|
||||
exercises: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||
sourceReferences: z.array(ZCourseImportSourceReference).optional(),
|
||||
warnings: z.array(ZCourseImportWarning).default([])
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
const sectionIds = new Set<string>();
|
||||
value.sections.forEach((section, index) => {
|
||||
if (sectionIds.has(section.externalId)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['sections', index, 'externalId'],
|
||||
message: 'Section externalId must be unique'
|
||||
});
|
||||
}
|
||||
sectionIds.add(section.externalId);
|
||||
});
|
||||
|
||||
const lessonIds = new Set<string>();
|
||||
value.lessons.forEach((lesson, index) => {
|
||||
if (lessonIds.has(lesson.externalId)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['lessons', index, 'externalId'],
|
||||
message: 'Lesson externalId must be unique'
|
||||
});
|
||||
}
|
||||
lessonIds.add(lesson.externalId);
|
||||
|
||||
if (!sectionIds.has(lesson.sectionExternalId)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['lessons', index, 'sectionExternalId'],
|
||||
message: 'Lesson sectionExternalId must reference an existing section'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
value.lessonLanguages.forEach((lessonLanguage, index) => {
|
||||
if (!lessonIds.has(lessonLanguage.lessonExternalId)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['lessonLanguages', index, 'lessonExternalId'],
|
||||
message: 'Lesson language must reference an existing lesson'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
export type TCourseImportDraftPayload = z.infer<typeof ZCourseImportDraftPayload>;
|
||||
|
||||
export const ZCourseImportDraftCreate = z.object({
|
||||
sourceType: z.enum(['prompt', 'pdf']),
|
||||
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 TCourseImportDraftCreate = z.infer<typeof ZCourseImportDraftCreate>;
|
||||
|
||||
export const ZCourseImportDraftGetParam = z.object({
|
||||
draftId: z.string().uuid()
|
||||
});
|
||||
export type TCourseImportDraftGetParam = z.infer<typeof ZCourseImportDraftGetParam>;
|
||||
|
||||
export const ZCourseImportDraftUpdate = z.object({
|
||||
summary: z.record(z.string(), z.unknown()).optional(),
|
||||
sourceArtifacts: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||
warnings: z.array(ZCourseImportWarning).optional(),
|
||||
draft: ZCourseImportDraftPayload.optional()
|
||||
});
|
||||
export type TCourseImportDraftUpdate = z.infer<typeof ZCourseImportDraftUpdate>;
|
||||
|
||||
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()
|
||||
});
|
||||
export type TCourseImportDraftPublish = z.infer<typeof ZCourseImportDraftPublish>;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './course-import';
|
||||
@@ -4,6 +4,7 @@ export * from './account';
|
||||
export * from './attendance';
|
||||
export * from './assets';
|
||||
export * from './course';
|
||||
export * from './course-import';
|
||||
export * from './exercise';
|
||||
export * from './lesson';
|
||||
export * from './mail';
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZOrganizationApiKeyType = z.enum(['mcp', 'api', 'zapier']);
|
||||
export type TOrganizationApiKeyType = z.infer<typeof ZOrganizationApiKeyType>;
|
||||
|
||||
export const ZOrganizationApiKeyScope = z.enum([
|
||||
'course_import:draft:create',
|
||||
'course_import:draft:read',
|
||||
'course_import:draft:update',
|
||||
'course_import:draft:publish',
|
||||
'public_api:*'
|
||||
]);
|
||||
export type TOrganizationApiKeyScope = z.infer<typeof ZOrganizationApiKeyScope>;
|
||||
|
||||
export const ZListOrganizationApiKeysQuery = z.object({
|
||||
type: ZOrganizationApiKeyType.optional()
|
||||
});
|
||||
export type TListOrganizationApiKeysQuery = z.infer<typeof ZListOrganizationApiKeysQuery>;
|
||||
|
||||
export const ZCreateOrganizationApiKey = z.object({
|
||||
type: ZOrganizationApiKeyType,
|
||||
label: z.string().min(1).max(120).optional(),
|
||||
expiresAt: z.string().datetime().optional(),
|
||||
scopes: z.array(ZOrganizationApiKeyScope).min(1).optional()
|
||||
});
|
||||
export type TCreateOrganizationApiKey = z.infer<typeof ZCreateOrganizationApiKey>;
|
||||
|
||||
export const ZOrganizationApiKeyParam = z.object({
|
||||
keyId: z.string().uuid()
|
||||
});
|
||||
export type TOrganizationApiKeyParam = z.infer<typeof ZOrganizationApiKeyParam>;
|
||||
@@ -5,3 +5,4 @@ export * from './invite';
|
||||
export * from './sso';
|
||||
export * from './login-link';
|
||||
export * from './token-auth';
|
||||
export * from './automation-key';
|
||||
|
||||
Generated
+520
-53
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,749 @@
|
||||
---
|
||||
name: MCP Course Authoring from Structured Payloads
|
||||
overview: Build an MCP-compatible authoring surface that lets external AI clients such as Claude send normalized course structures into ClassroomIO, save them as reviewable drafts, revise them, and publish them into real ClassroomIO courses.
|
||||
todos:
|
||||
- id: prd-review
|
||||
content: Finalize PRD scope, product policy, and success metrics with stakeholders
|
||||
status: pending
|
||||
- id: auth-model
|
||||
content: Define shared organization automation key system reused by MCP and Public API
|
||||
status: pending
|
||||
- id: import-schema
|
||||
content: Finalize normalized course payload schema shared by prompt and PDF-derived flows
|
||||
status: pending
|
||||
- id: import-validation
|
||||
content: Add validation module for structured draft create, update, and publish requests
|
||||
status: pending
|
||||
- id: import-data-model
|
||||
content: Add draft persistence tables and query layer
|
||||
status: pending
|
||||
- id: import-api
|
||||
content: Add organization-scoped draft and publish routes
|
||||
status: pending
|
||||
- id: mcp-package
|
||||
content: Build MCP package with stdio transport and ClassroomIO tool adapters
|
||||
status: pending
|
||||
- id: publish-flow
|
||||
content: Implement draft-to-course publish orchestration using existing course, section, lesson, and language services
|
||||
status: pending
|
||||
- id: idempotency
|
||||
content: Add idempotency keys and duplicate publish guards for MCP-triggered imports
|
||||
status: pending
|
||||
- id: observability
|
||||
content: Add structured logs, audit trail, and rate limits for MCP tools
|
||||
status: pending
|
||||
- id: docs
|
||||
content: Document MCP tool contracts, auth setup, payload schema, and agent responsibilities
|
||||
status: pending
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# MCP Course Authoring from Structured Payloads
|
||||
|
||||
## Status
|
||||
|
||||
- Draft
|
||||
|
||||
## Date
|
||||
|
||||
- March 12, 2026
|
||||
|
||||
## Purpose
|
||||
|
||||
Enable external AI clients such as Claude to create and edit ClassroomIO courses through MCP by sending normalized course structures into ClassroomIO.
|
||||
|
||||
The key product outcome is:
|
||||
|
||||
1. Claude or another agent reads source material externally
|
||||
2. Claude turns that material into structured course JSON
|
||||
3. MCP sends that JSON to ClassroomIO
|
||||
4. ClassroomIO stores it as a draft
|
||||
5. the user reviews and explicitly publishes it
|
||||
|
||||
This supports prompts like:
|
||||
|
||||
- "Create a complete course on exponential functions in Classroom."
|
||||
- "Read this PDF, extract the course structure, and publish it into Classroom."
|
||||
|
||||
with one important boundary:
|
||||
|
||||
**In v1, ClassroomIO does not parse or extract PDFs itself.**
|
||||
|
||||
## Problem Statement
|
||||
|
||||
ClassroomIO already has the core CRUD primitives needed to create courses and content, but it does not yet expose a clean automation layer for external AI agents.
|
||||
|
||||
Current limitations:
|
||||
|
||||
- external AI clients have no MCP-compatible tool surface
|
||||
- there is no normalized course payload contract for external agents
|
||||
- there is no first-class draft persistence model for AI-generated course structures
|
||||
- there is no explicit review/publish workflow for machine-generated content
|
||||
- there is no stable API contract for "here is a complete course structure, store it safely"
|
||||
|
||||
This means agents can only create ClassroomIO content through brittle, record-by-record automation instead of a deliberate authoring pipeline.
|
||||
|
||||
## Core Product Decision
|
||||
|
||||
**Claude or the external agent is responsible for extraction. ClassroomIO is responsible for validation, persistence, review, authorization, and publish.**
|
||||
|
||||
That means:
|
||||
|
||||
- if the source is a PDF, Claude reads it
|
||||
- if the source is a website, Claude reads it
|
||||
- if the source is a prompt, Claude expands it
|
||||
- ClassroomIO only receives structured payloads
|
||||
|
||||
## Primary Use Cases
|
||||
|
||||
### Use Case 1: Prompt to Structured Draft
|
||||
|
||||
A teacher asks Claude to create a course on a topic. Claude reasons about the content and sends a normalized course payload to ClassroomIO through MCP.
|
||||
|
||||
### Use Case 2: PDF to Structured Draft
|
||||
|
||||
A teacher provides one or more PDFs to Claude. Claude extracts the material and converts it into a normalized course payload. MCP sends that payload to ClassroomIO.
|
||||
|
||||
### Use Case 3: Draft Revision
|
||||
|
||||
After initial creation, Claude updates the draft by:
|
||||
|
||||
1. renaming sections
|
||||
2. rewriting lesson content
|
||||
3. reordering lessons
|
||||
4. adding or removing lessons
|
||||
5. publishing when the teacher confirms
|
||||
|
||||
## Goals
|
||||
|
||||
1. Provide an MCP-compatible authoring surface for external AI clients.
|
||||
2. Keep ClassroomIO as the source of truth for auth, permissions, validation, persistence, and publishing.
|
||||
3. Standardize a normalized course payload that any external agent can submit.
|
||||
4. Make draft review mandatory before publish in v1.
|
||||
5. Ensure org-scoped, permission-safe authoring with no direct DB access from the MCP server.
|
||||
6. Support both prompt-derived and PDF-derived content through one shared payload contract.
|
||||
7. Preserve human control by returning warnings and requiring explicit publish.
|
||||
|
||||
## Non-Goals (v1)
|
||||
|
||||
- Student-facing AI workflows
|
||||
- Direct DB writes from the MCP server
|
||||
- Full autonomous publishing without a review step
|
||||
- ClassroomIO-side PDF extraction, OCR, or parsing
|
||||
- Async PDF extraction workers
|
||||
- Cross-organization or cross-course content access
|
||||
- Full LMS migration parity with Teachable/Thinkific/SCORM in this PRD
|
||||
- Perfect fidelity for tables, diagrams, formulas, or source formatting
|
||||
|
||||
## Confirmed Product Decisions
|
||||
|
||||
1. MCP is an orchestration layer, not the persistence layer.
|
||||
2. ClassroomIO API owns validation, authorization, draft storage, and publish.
|
||||
3. Draft-first is required; AI-generated content is not published immediately in v1.
|
||||
4. External agents submit structured payloads; ClassroomIO does not ingest raw PDFs in v1.
|
||||
5. The MCP server should start as a thin tool adapter and not require its own model provider.
|
||||
6. Lesson long-form content should be stored in lesson language content, not only `lesson.note`.
|
||||
7. Publish remains an explicit second step.
|
||||
|
||||
## Current-State Audit
|
||||
|
||||
| Capability | Current State | Notes |
|
||||
| --- | --- | --- |
|
||||
| Course creation | Exists via `POST /course` | Creates course shell only |
|
||||
| Section creation | Exists via `POST /course/:courseId/section` | One section at a time |
|
||||
| Lesson creation | Exists via `POST /course/:courseId/lesson` | One lesson at a time |
|
||||
| Lesson language content | Exists via lesson-language routes | Good fit for generated lesson body |
|
||||
| Course content reorder | Exists via content/section/lesson reorder routes | Useful after generation |
|
||||
| Document upload | Exists for lesson documents | Not needed for v1 MCP ingestion |
|
||||
| AI course assistant | PRD exists | Focused on in-app chat, not MCP |
|
||||
| Public API keys | PRD exists | Likely dependency for auth model |
|
||||
| Draft persistence for AI-generated course structures | Does not exist yet | Must be added |
|
||||
|
||||
## Data Sources Checked
|
||||
|
||||
- [apps/api/src/routes/course/course.ts](/Users/rotimibest/_pros/classroomio/apps/api/src/routes/course/course.ts)
|
||||
- [apps/api/src/routes/course/section.ts](/Users/rotimibest/_pros/classroomio/apps/api/src/routes/course/section.ts)
|
||||
- [apps/api/src/routes/course/lesson.ts](/Users/rotimibest/_pros/classroomio/apps/api/src/routes/course/lesson.ts)
|
||||
- [apps/api/src/routes/course/lesson-language.ts](/Users/rotimibest/_pros/classroomio/apps/api/src/routes/course/lesson-language.ts)
|
||||
- [apps/api/src/services/course/course.ts](/Users/rotimibest/_pros/classroomio/apps/api/src/services/course/course.ts)
|
||||
- [apps/api/src/services/course/section.ts](/Users/rotimibest/_pros/classroomio/apps/api/src/services/course/section.ts)
|
||||
- [apps/api/src/services/lesson/lesson.ts](/Users/rotimibest/_pros/classroomio/apps/api/src/services/lesson/lesson.ts)
|
||||
- [apps/api/src/services/lesson-language.ts](/Users/rotimibest/_pros/classroomio/apps/api/src/services/lesson-language.ts)
|
||||
- [packages/utils/src/validation/course/course.ts](/Users/rotimibest/_pros/classroomio/packages/utils/src/validation/course/course.ts)
|
||||
- [packages/utils/src/validation/lesson/lesson.ts](/Users/rotimibest/_pros/classroomio/packages/utils/src/validation/lesson/lesson.ts)
|
||||
- [packages/utils/src/validation/lesson/language.ts](/Users/rotimibest/_pros/classroomio/packages/utils/src/validation/lesson/language.ts)
|
||||
- [prd/ai-course-assistant/README.md](/Users/rotimibest/_pros/classroomio/prd/ai-course-assistant/README.md)
|
||||
- [prd/public-api/README.md](/Users/rotimibest/_pros/classroomio/prd/public-api/README.md)
|
||||
|
||||
## User Personas
|
||||
|
||||
### Persona A: Solo Teacher
|
||||
|
||||
Wants to turn source material into a structured first course quickly, then lightly edit before publish.
|
||||
|
||||
### Persona B: Training Operator
|
||||
|
||||
Uses Claude to convert internal manuals or SOPs into structured staff training without rebuilding content manually.
|
||||
|
||||
### Persona C: Content Team
|
||||
|
||||
Uses Claude as an operator to draft or refactor course structures programmatically across multiple organizations and topics.
|
||||
|
||||
## Jobs To Be Done
|
||||
|
||||
1. Convert source intent or material into a usable draft course structure.
|
||||
2. Persist AI-generated course structures safely before publish.
|
||||
3. Review machine-generated content before it affects learner-facing content.
|
||||
4. Keep content generation inside org permissions and audit boundaries.
|
||||
5. Avoid brittle record-by-record automation from external agents.
|
||||
|
||||
## Product Principles
|
||||
|
||||
1. Draft before publish.
|
||||
2. API-first, not DB-first.
|
||||
3. One normalized course payload regardless of how the agent derived it.
|
||||
4. External agents think; ClassroomIO validates and persists.
|
||||
5. Warnings are first-class output, not hidden logs.
|
||||
|
||||
## Proposed Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph client [AI Client]
|
||||
Claude["Claude or other MCP client"]
|
||||
Source["Prompt / PDF / Website / Notes"]
|
||||
end
|
||||
subgraph mcp [MCP Server]
|
||||
Tools["MCP tools"]
|
||||
Adapter["ClassroomIO API adapter"]
|
||||
end
|
||||
subgraph api [ClassroomIO API]
|
||||
Auth["Org-scoped automation key auth"]
|
||||
DraftRoutes["Draft routes"]
|
||||
DraftService["Draft orchestration service"]
|
||||
PublishService["Draft publish service"]
|
||||
end
|
||||
subgraph db [Data Layer]
|
||||
DraftTable["course_import_draft"]
|
||||
CourseTables["course / section / lesson / lesson_language"]
|
||||
end
|
||||
|
||||
Source --> Claude
|
||||
Claude --> Tools
|
||||
Tools --> Adapter
|
||||
Adapter --> Auth
|
||||
Auth --> DraftRoutes
|
||||
DraftRoutes --> DraftService
|
||||
DraftService --> DraftTable
|
||||
DraftService --> PublishService
|
||||
PublishService --> CourseTables
|
||||
```
|
||||
|
||||
## Scope Overview
|
||||
|
||||
### In Scope
|
||||
|
||||
- MCP tool surface for structured course authoring workflows
|
||||
- organization-scoped authentication for MCP clients
|
||||
- draft persistence and preview APIs
|
||||
- publish flow from draft to real course content
|
||||
- warnings, audit logging, and idempotency
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- raw PDF ingestion by ClassroomIO
|
||||
- OCR, parsing, or extraction jobs
|
||||
- rich in-dashboard chat UX
|
||||
- student preview or learner-side AI guidance
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
### FR-1 MCP Tool Surface
|
||||
|
||||
The MCP server must expose a minimal, stable set of tools for AI clients:
|
||||
|
||||
- `create_course_draft`
|
||||
- `get_course_draft`
|
||||
- `update_course_draft`
|
||||
- `publish_course_draft`
|
||||
- `list_org_courses`
|
||||
- `get_course_structure`
|
||||
|
||||
The MCP server must not write directly to the database.
|
||||
|
||||
### FR-2 Authentication and Scoping
|
||||
|
||||
All MCP actions must be authenticated and scoped to an organization.
|
||||
|
||||
Requirements:
|
||||
|
||||
- use organization-scoped automation keys
|
||||
- reuse the same underlying key system as the Public API
|
||||
- allow MCP to use a narrower scope preset than generic API access
|
||||
- every request resolves a single org context
|
||||
- mutations require org admin or authorized course team membership
|
||||
- cross-org access must be rejected
|
||||
|
||||
### FR-3 Normalized Course Payload
|
||||
|
||||
All external agents must submit the same normalized course payload.
|
||||
|
||||
Minimum payload shape:
|
||||
|
||||
- course metadata
|
||||
- sections
|
||||
- lessons
|
||||
- lesson content by locale
|
||||
- optional exercises
|
||||
- source references
|
||||
- warnings
|
||||
|
||||
### FR-4 Draft Review
|
||||
|
||||
Generated content must remain in draft state until explicitly published.
|
||||
|
||||
The draft API must allow:
|
||||
|
||||
- fetching current draft
|
||||
- patching draft metadata
|
||||
- adding/removing/reordering sections and lessons
|
||||
- updating lesson titles and language content
|
||||
- inspecting warnings before publish
|
||||
|
||||
### FR-5 Structured Payload Ingestion
|
||||
|
||||
Users must be able to create a draft course by submitting a normalized course payload.
|
||||
|
||||
The API must not care whether the payload came from:
|
||||
|
||||
- a direct prompt
|
||||
- a PDF processed by Claude
|
||||
- a website processed by Claude
|
||||
- manually authored JSON
|
||||
|
||||
### FR-6 Publish Flow
|
||||
|
||||
Publishing a draft must create or update real ClassroomIO content through the existing service layer.
|
||||
|
||||
Publish orchestration should:
|
||||
|
||||
1. create course shell
|
||||
2. create sections
|
||||
3. create lessons
|
||||
4. create lesson-language content
|
||||
5. optionally create exercises in later scope
|
||||
6. return created IDs and publish summary
|
||||
|
||||
### FR-7 Idempotency and Retry Safety
|
||||
|
||||
MCP-triggered operations must be safe to retry.
|
||||
|
||||
Requirements:
|
||||
|
||||
- accept idempotency key on draft creation and publish
|
||||
- prevent duplicate course creation on publish retries
|
||||
|
||||
### FR-8 Observability
|
||||
|
||||
The system must expose enough detail for debugging and support.
|
||||
|
||||
Requirements:
|
||||
|
||||
- structured logs per draft create/update/publish action
|
||||
- audit trail for actor, org, tool, and created course IDs
|
||||
- warning list available to the AI client and internal admins
|
||||
- rate limits per org and per key
|
||||
|
||||
## Data Model
|
||||
|
||||
### New Table: `course_import_draft`
|
||||
|
||||
Fields:
|
||||
|
||||
- `id`
|
||||
- `organizationId`
|
||||
- `createdByProfileId`
|
||||
- `sourceType`
|
||||
- `status`
|
||||
- `title`
|
||||
- `locale`
|
||||
- `idempotencyKey`
|
||||
- `summary`
|
||||
- `draft`
|
||||
- `warnings`
|
||||
- `sourceArtifacts`
|
||||
- `publishedCourseId`
|
||||
- timestamps
|
||||
|
||||
This table stores the normalized draft payload and the publish result.
|
||||
|
||||
## Normalized Payload Contract
|
||||
|
||||
```ts
|
||||
type CourseDraft = {
|
||||
course: {
|
||||
title: string;
|
||||
description: string;
|
||||
type: 'LIVE_CLASS' | 'SELF_PACED';
|
||||
locale: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
sections: Array<{
|
||||
externalId: string;
|
||||
title: string;
|
||||
order: number;
|
||||
}>;
|
||||
lessons: Array<{
|
||||
externalId: string;
|
||||
sectionExternalId: string;
|
||||
title: string;
|
||||
order: number;
|
||||
isUnlocked?: boolean;
|
||||
public?: boolean;
|
||||
}>;
|
||||
lessonLanguages: Array<{
|
||||
lessonExternalId: string;
|
||||
locale: string;
|
||||
content: string;
|
||||
}>;
|
||||
exercises?: Array<Record<string, unknown>>;
|
||||
sourceReferences?: Array<{
|
||||
type: 'prompt' | 'pdf';
|
||||
label: string;
|
||||
pageStart?: number;
|
||||
pageEnd?: number;
|
||||
}>;
|
||||
warnings: Array<{
|
||||
code: string;
|
||||
message: string;
|
||||
severity: 'info' | 'warning' | 'error';
|
||||
}>;
|
||||
};
|
||||
```
|
||||
|
||||
## Proposed API Design
|
||||
|
||||
### Validation
|
||||
|
||||
Add:
|
||||
|
||||
- `packages/utils/src/validation/course-import/course-import.ts`
|
||||
|
||||
Schemas:
|
||||
|
||||
- `ZCourseImportDraftCreate`
|
||||
- `ZCourseImportDraftGetParam`
|
||||
- `ZCourseImportDraftUpdate`
|
||||
- `ZCourseImportDraftPublish`
|
||||
|
||||
### Routes
|
||||
|
||||
Add organization-scoped route group:
|
||||
|
||||
- `apps/api/src/routes/organization/course-import.ts`
|
||||
|
||||
Suggested endpoints:
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `POST` | `/organization/course-import/drafts` | Create draft from normalized payload |
|
||||
| `GET` | `/organization/course-import/drafts/:draftId` | Get draft |
|
||||
| `PUT` | `/organization/course-import/drafts/:draftId` | Update draft |
|
||||
| `POST` | `/organization/course-import/drafts/:draftId/publish` | Publish draft into course |
|
||||
|
||||
All routes must return a single response shape and follow existing route conventions.
|
||||
|
||||
### Services
|
||||
|
||||
Add:
|
||||
|
||||
- `apps/api/src/services/course-import/course-import.ts`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- draft persistence
|
||||
- validation of structured payloads
|
||||
- publish orchestration using current course/section/lesson/language services
|
||||
|
||||
## MCP Server Design
|
||||
|
||||
### Placement
|
||||
|
||||
Recommended:
|
||||
|
||||
- `apps/mcp`
|
||||
or
|
||||
- `packages/mcp`
|
||||
|
||||
Start with stdio transport for Claude Desktop and local agent usage.
|
||||
|
||||
### Responsibilities
|
||||
|
||||
- expose MCP tools
|
||||
- validate tool input
|
||||
- forward requests to ClassroomIO API
|
||||
- format structured responses for the AI client
|
||||
|
||||
### Non-Responsibilities
|
||||
|
||||
- direct DB access
|
||||
- authorization decisions independent of ClassroomIO
|
||||
- content extraction from source materials
|
||||
- PDF parsing or OCR
|
||||
|
||||
### Tool Contracts
|
||||
|
||||
#### `create_course_draft`
|
||||
|
||||
Input:
|
||||
|
||||
- `organizationId`
|
||||
- normalized `draft`
|
||||
- `sourceType`
|
||||
- `idempotencyKey?`
|
||||
- `summary?`
|
||||
- `sourceArtifacts?`
|
||||
|
||||
Output:
|
||||
|
||||
- `draftId`
|
||||
- `summary`
|
||||
- `warnings`
|
||||
|
||||
#### `get_course_draft`
|
||||
|
||||
Input:
|
||||
|
||||
- `draftId`
|
||||
|
||||
Output:
|
||||
|
||||
- stored draft payload
|
||||
- summary
|
||||
- warnings
|
||||
- publish status
|
||||
|
||||
#### `update_course_draft`
|
||||
|
||||
Input:
|
||||
|
||||
- `draftId`
|
||||
- patch payload
|
||||
|
||||
Output:
|
||||
|
||||
- updated summary
|
||||
- warnings
|
||||
|
||||
#### `publish_course_draft`
|
||||
|
||||
Input:
|
||||
|
||||
- `draftId`
|
||||
- optional overrides for title/description/type/metadata
|
||||
|
||||
Output:
|
||||
|
||||
- `courseId`
|
||||
- `createdSections`
|
||||
- `createdLessons`
|
||||
- `localeCount`
|
||||
|
||||
## Shared API Key Design
|
||||
|
||||
MCP and Public API should reuse the same underlying key infrastructure.
|
||||
|
||||
They should not reuse the same default secret for every use case.
|
||||
|
||||
Recommended model:
|
||||
|
||||
- one shared `organization_api_key` table
|
||||
- one shared bearer-auth middleware
|
||||
- different key presets by integration type
|
||||
|
||||
Recommended key fields:
|
||||
|
||||
- `id`
|
||||
- `organizationId`
|
||||
- `type`: `mcp | api | zapier`
|
||||
- `label`
|
||||
- `secretPrefix`
|
||||
- `secretHash`
|
||||
- `scopes`
|
||||
- `createdByProfileId`
|
||||
- `lastUsedAt`
|
||||
- `expiresAt`
|
||||
- `revokedAt`
|
||||
- `createdAt`
|
||||
- `updatedAt`
|
||||
|
||||
Recommended key handling:
|
||||
|
||||
- show raw secret once on creation
|
||||
- store only the hash
|
||||
- allow multiple active keys per organization
|
||||
- encourage one key per integration or agent
|
||||
|
||||
Recommended preset policy:
|
||||
|
||||
- `mcp` preset:
|
||||
- draft create/read/update
|
||||
- draft publish
|
||||
- `api` preset:
|
||||
- broader route-specific scopes selected by admin
|
||||
|
||||
## Route Design
|
||||
|
||||
Shared management routes should live under organization automation, not a separate MCP-only surface.
|
||||
|
||||
Recommended management routes:
|
||||
|
||||
- `GET /organization/automation/keys`
|
||||
- `POST /organization/automation/keys`
|
||||
- `DELETE /organization/automation/keys/:keyId`
|
||||
- `POST /organization/automation/keys/:keyId/rotate`
|
||||
|
||||
Recommended request fields for create:
|
||||
|
||||
- `type`
|
||||
- `label`
|
||||
- `preset`
|
||||
- `scopes` optional for custom API keys
|
||||
- `expiresAt` optional
|
||||
|
||||
Recommended auth flow for machine requests:
|
||||
|
||||
1. client sends `Authorization: Bearer <key>`
|
||||
2. middleware hashes key
|
||||
3. API resolves organization from stored key row
|
||||
4. API attaches scopes and key metadata to context
|
||||
5. route checks scope and org permissions
|
||||
|
||||
## Automation UI Design
|
||||
|
||||
The management UI should live in the org sidebar as `Automation`.
|
||||
|
||||
Tabs:
|
||||
|
||||
- `MCP`
|
||||
- `Zapier` disabled, coming soon
|
||||
- `API` disabled, coming soon until public API ships
|
||||
|
||||
MCP tab responsibilities:
|
||||
|
||||
- explain what MCP does
|
||||
- generate org-scoped MCP keys
|
||||
- list active MCP keys
|
||||
- revoke or rotate keys
|
||||
- show setup snippets for:
|
||||
- Claude Code
|
||||
- Codex
|
||||
- Cursor
|
||||
|
||||
Permissions:
|
||||
|
||||
- org admins can create, rotate, revoke
|
||||
- teachers can open the page but see the controls disabled
|
||||
- all server mutations remain admin-only
|
||||
|
||||
## UX and Review Model
|
||||
|
||||
The review loop is:
|
||||
|
||||
1. external agent creates draft
|
||||
2. user inspects summary and warnings
|
||||
3. external agent revises draft if needed
|
||||
4. user explicitly publishes
|
||||
|
||||
The AI client should never assume silent publish in v1.
|
||||
|
||||
## Failure Modes and Warnings
|
||||
|
||||
The system must capture and expose at least these issues:
|
||||
|
||||
- duplicate external IDs
|
||||
- lessons referencing missing sections
|
||||
- lesson-language entries referencing missing lessons
|
||||
- empty or invalid titles/content
|
||||
- invalid metadata shape
|
||||
- duplicate publish attempts
|
||||
|
||||
Publishing should be blocked on validation errors. Informational warnings may still allow publish.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. Reject cross-org access for all draft and publish routes.
|
||||
2. Enforce org admin or equivalent authoring permissions.
|
||||
3. Log key ID, key type, organization, and output course IDs for all MCP-triggered publishes.
|
||||
4. Apply rate limits per org and per key.
|
||||
5. Keep the MCP server outside the database trust boundary.
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### Product Metrics
|
||||
|
||||
1. Time from agent output to first draft available
|
||||
2. Draft-to-publish conversion rate
|
||||
3. Average manual edits before publish
|
||||
4. Percentage of generated drafts published within 24 hours
|
||||
|
||||
### Quality Metrics
|
||||
|
||||
1. Percentage of drafts requiring no structural edits
|
||||
2. Publish failure rate
|
||||
3. Duplicate publish rate
|
||||
4. Support tickets related to malformed structure payloads
|
||||
|
||||
## Rollout Plan
|
||||
|
||||
### Phase 1: Draft and Publish API
|
||||
|
||||
Ship:
|
||||
|
||||
- normalized payload schema
|
||||
- draft create endpoint
|
||||
- draft update endpoint
|
||||
- publish endpoint
|
||||
|
||||
### Phase 2: MCP Server
|
||||
|
||||
Ship:
|
||||
|
||||
- stdio MCP server
|
||||
- auth wiring
|
||||
- tool contracts for create/get/update/publish
|
||||
|
||||
### Phase 3: Hardening
|
||||
|
||||
Ship:
|
||||
|
||||
- idempotency keys
|
||||
- richer warnings
|
||||
- observability
|
||||
- docs and examples
|
||||
|
||||
## Dependencies
|
||||
|
||||
1. Existing course, section, lesson, and lesson-language services remain the publish target.
|
||||
2. Shared organization automation key infrastructure must be implemented.
|
||||
3. External agent must generate normalized course payloads that match the schema.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. Should `publish_course_draft` remain a separate tool only, or support an explicit `publish: true` flag during draft creation?
|
||||
2. Should exercise payloads be part of v1 or deferred until lesson drafting stabilizes?
|
||||
3. What locale set should be supported by default?
|
||||
4. Should cloud-only restrictions apply, similar to the AI assistant PRD?
|
||||
5. Should the shared key-management routes ship before public `/v1/*` API routes, so MCP can adopt them first?
|
||||
|
||||
## Recommendation Summary
|
||||
|
||||
The correct v1 shape is:
|
||||
|
||||
1. thin MCP server
|
||||
2. ClassroomIO-owned draft/publish API
|
||||
3. external agent owns source extraction
|
||||
4. explicit draft review before publish
|
||||
5. publish via existing content services, not custom DB writes
|
||||
|
||||
That keeps the system small, matches your clarified boundary, and gives Claude a clean automation surface without forcing ClassroomIO to solve PDF extraction in v1.
|
||||
@@ -0,0 +1,357 @@
|
||||
# MCP Consumption Spec
|
||||
|
||||
## Purpose
|
||||
|
||||
Define exactly how an MCP client such as Claude Desktop or another agent runtime should consume the ClassroomIO MCP server.
|
||||
|
||||
This spec assumes:
|
||||
|
||||
1. the agent performs source extraction externally
|
||||
2. the MCP server only accepts structured course payloads
|
||||
3. ClassroomIO API is the system of record
|
||||
|
||||
## Consumption Model
|
||||
|
||||
```text
|
||||
User prompt/source material
|
||||
-> Claude or agent
|
||||
-> MCP tool call
|
||||
-> ClassroomIO API
|
||||
-> ClassroomIO DB
|
||||
```
|
||||
|
||||
The MCP server is consumed as a tool surface, not as a conversational model.
|
||||
|
||||
## Agent Responsibilities
|
||||
|
||||
The agent must:
|
||||
|
||||
1. read source material such as PDFs, notes, prompts, or websites
|
||||
2. infer a structured course outline
|
||||
3. construct a normalized payload that matches the draft schema
|
||||
4. call MCP tools with that payload
|
||||
5. handle validation errors and revise payloads when needed
|
||||
6. only publish after explicit user confirmation
|
||||
|
||||
The agent must not:
|
||||
|
||||
1. write directly to the ClassroomIO database
|
||||
2. assume ClassroomIO will parse raw PDFs
|
||||
3. assume drafts are automatically published
|
||||
|
||||
## Authentication Model
|
||||
|
||||
### Target model
|
||||
|
||||
Use an organization-scoped MCP key generated from the org `Automation -> MCP` tab.
|
||||
|
||||
That key should come from the same shared key infrastructure later reused by the Public API.
|
||||
|
||||
The MCP server forwards that credential to ClassroomIO on every request.
|
||||
|
||||
Example environment:
|
||||
|
||||
```bash
|
||||
CLASSROOMIO_API_URL=https://api.classroomio.example
|
||||
CLASSROOMIO_API_KEY=cio_mcp_xxxxx
|
||||
```
|
||||
|
||||
Recommended request header:
|
||||
|
||||
```text
|
||||
Authorization: Bearer cio_mcp_xxxxx
|
||||
```
|
||||
|
||||
### Current bootstrap implementation
|
||||
|
||||
Until the shared organization key system lands, the local package uses:
|
||||
|
||||
```text
|
||||
Authorization: Bearer <PRIVATE_SERVER_KEY>
|
||||
cio-org-id: <organization-id>
|
||||
x-cio-actor-id: <profile-id>
|
||||
```
|
||||
|
||||
That is an implementation bridge, not the intended long-term auth model.
|
||||
|
||||
## MCP Transport
|
||||
|
||||
### v1
|
||||
|
||||
- `stdio`
|
||||
|
||||
This is the best default for Claude Desktop and local agent environments.
|
||||
|
||||
### Future
|
||||
|
||||
- SSE or HTTP transport if remote/shared infrastructure is needed
|
||||
|
||||
## Tool Surface
|
||||
|
||||
### `create_course_draft`
|
||||
|
||||
Creates a reviewable draft from a normalized payload.
|
||||
|
||||
Input:
|
||||
|
||||
```json
|
||||
{
|
||||
"sourceType": "prompt",
|
||||
"idempotencyKey": "optional-key",
|
||||
"summary": {
|
||||
"sourceLabel": "Exponential Functions PDF"
|
||||
},
|
||||
"sourceArtifacts": [
|
||||
{
|
||||
"type": "pdf",
|
||||
"label": "chapter-1.pdf"
|
||||
}
|
||||
],
|
||||
"draft": {
|
||||
"course": {
|
||||
"title": "Exponential Functions",
|
||||
"description": "A structured introduction to exponential functions.",
|
||||
"type": "SELF_PACED",
|
||||
"locale": "en"
|
||||
},
|
||||
"sections": [
|
||||
{
|
||||
"externalId": "sec-1",
|
||||
"title": "Foundations",
|
||||
"order": 0
|
||||
}
|
||||
],
|
||||
"lessons": [
|
||||
{
|
||||
"externalId": "lesson-1",
|
||||
"sectionExternalId": "sec-1",
|
||||
"title": "What Is an Exponential Function?",
|
||||
"order": 0
|
||||
}
|
||||
],
|
||||
"lessonLanguages": [
|
||||
{
|
||||
"lessonExternalId": "lesson-1",
|
||||
"locale": "en",
|
||||
"content": "<p>Lesson content here</p>"
|
||||
}
|
||||
],
|
||||
"warnings": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```json
|
||||
{
|
||||
"draftId": "uuid",
|
||||
"status": "DRAFT",
|
||||
"summary": {
|
||||
"sectionCount": 1,
|
||||
"lessonCount": 1,
|
||||
"localeCount": 1,
|
||||
"warningCount": 0
|
||||
},
|
||||
"warnings": []
|
||||
}
|
||||
```
|
||||
|
||||
### `get_course_draft`
|
||||
|
||||
Fetches the current stored draft.
|
||||
|
||||
Input:
|
||||
|
||||
```json
|
||||
{
|
||||
"draftId": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"status": "DRAFT",
|
||||
"draft": {
|
||||
"...": "normalized payload"
|
||||
},
|
||||
"warnings": []
|
||||
}
|
||||
```
|
||||
|
||||
### `update_course_draft`
|
||||
|
||||
Updates a stored draft with revised structure or content.
|
||||
|
||||
Input:
|
||||
|
||||
```json
|
||||
{
|
||||
"draftId": "uuid",
|
||||
"draft": {
|
||||
"...": "updated normalized payload"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```json
|
||||
{
|
||||
"draftId": "uuid",
|
||||
"status": "DRAFT",
|
||||
"summary": {
|
||||
"sectionCount": 3,
|
||||
"lessonCount": 12,
|
||||
"localeCount": 1,
|
||||
"warningCount": 1
|
||||
},
|
||||
"warnings": [
|
||||
{
|
||||
"code": "LOW_CONFIDENCE_BOUNDARY",
|
||||
"message": "Section split was inferred.",
|
||||
"severity": "warning"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `publish_course_draft`
|
||||
|
||||
Publishes a stored draft into real ClassroomIO entities.
|
||||
|
||||
Input:
|
||||
|
||||
```json
|
||||
{
|
||||
"draftId": "uuid",
|
||||
"title": "Optional override",
|
||||
"description": "Optional override",
|
||||
"type": "SELF_PACED"
|
||||
}
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```json
|
||||
{
|
||||
"courseId": "uuid",
|
||||
"createdSections": 3,
|
||||
"createdLessons": 12,
|
||||
"localeCount": 1
|
||||
}
|
||||
```
|
||||
|
||||
## Expected Agent Workflow
|
||||
|
||||
### Flow A: Prompt-Based Course Creation
|
||||
|
||||
1. User asks Claude to create a course.
|
||||
2. Claude plans the structure.
|
||||
3. Claude calls `create_course_draft`.
|
||||
4. Claude presents the summary to the user.
|
||||
5. User requests revisions if needed.
|
||||
6. Claude calls `update_course_draft`.
|
||||
7. User confirms publish.
|
||||
8. Claude calls `publish_course_draft`.
|
||||
|
||||
### Flow B: PDF-Based Course Creation
|
||||
|
||||
1. User provides PDF to Claude.
|
||||
2. Claude extracts and structures the content externally.
|
||||
3. Claude builds normalized payload.
|
||||
4. Claude calls `create_course_draft`.
|
||||
5. Claude presents draft summary and warnings.
|
||||
6. Claude revises if needed.
|
||||
7. Claude publishes only after user confirmation.
|
||||
|
||||
## Draft Schema Rules
|
||||
|
||||
The agent must ensure:
|
||||
|
||||
1. `section.externalId` values are unique
|
||||
2. `lesson.externalId` values are unique
|
||||
3. every `lesson.sectionExternalId` references an existing section
|
||||
4. every `lessonLanguage.lessonExternalId` references an existing lesson
|
||||
5. all required text fields are non-empty
|
||||
|
||||
If these rules are violated, ClassroomIO should reject the request with validation errors.
|
||||
|
||||
## Error Handling Contract
|
||||
|
||||
### Validation Errors
|
||||
|
||||
The MCP client should expect 400-level errors when:
|
||||
|
||||
- the payload shape is invalid
|
||||
- section or lesson references are broken
|
||||
- required fields are missing
|
||||
|
||||
Expected behavior:
|
||||
|
||||
1. surface the error to the agent
|
||||
2. revise payload
|
||||
3. retry with corrected structure
|
||||
|
||||
### Authorization Errors
|
||||
|
||||
The MCP client should expect:
|
||||
|
||||
- `401` for missing or invalid auth
|
||||
- `403` for valid auth but insufficient org permission
|
||||
|
||||
### Publish Errors
|
||||
|
||||
If publish fails:
|
||||
|
||||
1. do not assume partial success
|
||||
2. inspect returned error
|
||||
3. fetch the draft again if needed
|
||||
4. retry only if the failure is clearly safe to retry
|
||||
|
||||
## Idempotency Guidance
|
||||
|
||||
The MCP client should send an `idempotencyKey` on draft creation when:
|
||||
|
||||
- the same source material may be retried
|
||||
- the same user prompt may be replayed
|
||||
- the client cannot guarantee exactly-once execution
|
||||
|
||||
Use one stable key per logical draft creation request.
|
||||
|
||||
## Recommended User Experience in Claude
|
||||
|
||||
The agent should present:
|
||||
|
||||
1. course title
|
||||
2. section count
|
||||
3. lesson count
|
||||
4. warnings
|
||||
5. a publish confirmation prompt before calling `publish_course_draft`
|
||||
|
||||
The agent should not auto-publish immediately after draft creation.
|
||||
|
||||
## Minimal v1 MCP Server Requirements
|
||||
|
||||
The MCP server should:
|
||||
|
||||
1. expose the draft/publish tools above
|
||||
2. validate required inputs before forwarding
|
||||
3. forward auth and org context to ClassroomIO
|
||||
4. return structured JSON outputs to the client
|
||||
5. avoid embedding model logic
|
||||
|
||||
## Future Extensions
|
||||
|
||||
Possible future tools:
|
||||
|
||||
- `list_course_drafts`
|
||||
- `delete_course_draft`
|
||||
- `clone_course_structure`
|
||||
- `add_exercises_to_draft`
|
||||
- `preview_publish_diff`
|
||||
|
||||
These are not required for v1.
|
||||
@@ -0,0 +1,56 @@
|
||||
# MCP Course Authoring from Structured Payloads Implementation Plan
|
||||
|
||||
## Scope Lock (v1)
|
||||
|
||||
1. The MCP server is a thin API adapter, not a persistence layer.
|
||||
2. Draft review is mandatory before publish.
|
||||
3. External agents submit normalized course payloads.
|
||||
4. ClassroomIO does not parse PDFs or source material in v1.
|
||||
5. Publish uses existing ClassroomIO services for course, section, lesson, and lesson language creation.
|
||||
6. Authentication is org-scoped and must reject cross-org mutations.
|
||||
|
||||
## Delivery Phases
|
||||
|
||||
1. Phase A: Payload schema, validation, and draft persistence
|
||||
2. Phase B: Draft create/get/update API
|
||||
3. Phase C: Draft publish orchestration
|
||||
4. Phase D: MCP server and tool contracts
|
||||
5. Phase E: Observability, auth hardening, and idempotency
|
||||
|
||||
## Ticket Breakdown
|
||||
|
||||
| ID | Area | Task | Key Files | Dependencies | Done When |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| MCP-A1 | Validation | Add structured course payload validation module | `packages/utils/src/validation/course-import/course-import.ts` | None | Schemas exported and built |
|
||||
| MCP-A2 | DB | Add draft persistence table | `packages/db/src/schema.ts` | None | Draft schema updated |
|
||||
| MCP-A3 | DB | Add draft query module | `packages/db/src/queries/course-import/course-import.ts` | MCP-A2 | CRUD/query helpers exist |
|
||||
| MCP-A4 | API | Add draft route skeleton | `apps/api/src/routes/organization/course-import.ts` | MCP-A1 | Route group registered |
|
||||
| MCP-B1 | API | Implement draft create service | `apps/api/src/services/course-import/course-import.ts` | MCP-A4 | Structured payload creates draft |
|
||||
| MCP-B2 | API | Implement draft fetch/update endpoints | `apps/api/src/routes/organization/course-import.ts` | MCP-B1 | Draft can be reviewed and edited |
|
||||
| MCP-C1 | API | Implement draft publish service | `apps/api/src/services/course-import/course-import.ts` | MCP-B2 | Draft creates course, sections, lessons, languages |
|
||||
| MCP-C2 | API | Add idempotent publish behavior | `apps/api/src/services/course-import/course-import.ts` | MCP-C1 | Retries do not duplicate courses |
|
||||
| MCP-D1 | MCP | Scaffold MCP package | `apps/mcp/` or `packages/mcp/` | MCP-B2 | Stdio server runs locally |
|
||||
| MCP-D2 | MCP | Add `create_course_draft` tool | MCP server tool files | MCP-D1, MCP-B1 | Claude can create drafts |
|
||||
| MCP-D3 | MCP | Add `get_course_draft` and `update_course_draft` tools | MCP server tool files | MCP-B2 | Claude can inspect and revise drafts |
|
||||
| MCP-D4 | MCP | Add `publish_course_draft` tool | MCP server tool files | MCP-C2 | Claude can publish reviewed drafts |
|
||||
| MCP-E1 | Security | Enforce org-scoped auth and rate limits | middleware + routes | MCP-A4 | Unauthorized access returns 401/403 |
|
||||
| MCP-E2 | Observability | Add structured logging and audit trail | services + middleware | MCP-C1 | Draft and publish actions are traceable |
|
||||
| MCP-E3 | Docs | Document setup and consumption contract | `prd/mcp-course-authoring/` and docs follow-up | MCP-D4 | Operator docs exist |
|
||||
|
||||
## Suggested First Sprint Slice
|
||||
|
||||
1. Draft schema and table
|
||||
2. Draft create endpoint
|
||||
3. Draft get/update endpoint
|
||||
4. Draft publish endpoint
|
||||
5. Minimal MCP server with:
|
||||
- `create_course_draft`
|
||||
- `get_course_draft`
|
||||
- `publish_course_draft`
|
||||
|
||||
## Build and Verification Commands
|
||||
|
||||
1. `pnpm --filter @cio/utils build`
|
||||
2. `pnpm --filter @cio/db build`
|
||||
3. `pnpm --filter @cio/api build`
|
||||
4. Build the MCP package/app once created
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: Public API with Keys
|
||||
overview: Build a versioned public API (v1) authenticated via organization-scoped API keys, with course CRUD, course export, student/audience CRUD endpoints, plus a dashboard Automation page with API key management UI.
|
||||
overview: Build a versioned public API (v1) authenticated via shared organization-scoped automation keys reused by MCP and Public API, with course CRUD, course export, student/audience CRUD endpoints, plus a dashboard Automation page with MCP, Zapier, and API tabs.
|
||||
todos:
|
||||
- id: db-schema
|
||||
content: Add `api_key` table to schema.ts, update relations, generate migration
|
||||
@@ -12,13 +12,13 @@ todos:
|
||||
content: Build apiKeyMiddleware that authenticates via Bearer token and sets orgId in context
|
||||
status: pending
|
||||
- id: validation-schemas
|
||||
content: Add Zod schemas for API key management and public API input validation
|
||||
content: Add Zod schemas for shared automation key management and public API input validation
|
||||
status: pending
|
||||
- id: api-key-service
|
||||
content: Create api-key service (key generation, hashing, CRUD)
|
||||
status: pending
|
||||
- id: api-key-routes
|
||||
content: Create session-authenticated API key management routes (POST/GET/DELETE /api-key)
|
||||
content: Create session-authenticated automation key management routes reused by MCP and Public API
|
||||
status: pending
|
||||
- id: v1-course-service
|
||||
content: Create v1 course service (list, get, create, update, delete, export, students)
|
||||
@@ -39,7 +39,7 @@ todos:
|
||||
content: Add Automation nav item to org-navigation.ts
|
||||
status: pending
|
||||
- id: automation-page
|
||||
content: Create automation page with Tabs (API/Zapier/Webhooks), free plan gate, API key display
|
||||
content: Create automation page with Tabs (MCP/Zapier/API), admin-only controls, and key setup flows
|
||||
status: pending
|
||||
- id: automation-api-class
|
||||
content: Create frontend API class for api-key management
|
||||
|
||||
Reference in New Issue
Block a user