feat: implement automation feature

This commit is contained in:
Rotimi Best
2026-03-18 23:39:12 +01:00
parent 576d0366de
commit ef4424e267
121 changed files with 4544 additions and 1055 deletions
+10 -1
View File
@@ -42,9 +42,18 @@ PRIVATE_SERVER_URL=http://api:3081
# When set, invite emails and other links point here instead of app.classroomio.com
DASHBOARD_ORIGIN=http://localhost:3082
# CSP: external domains allowed by Content-Security-Policy (required when self-hosted)
# CSP: external domains allowed by Content-Security-Policy.
# These are runtime env vars — set them when running the container (no rebuild needed).
# Comma-separated. Include your CDN, API, video, and font domains.
# ALLOWED_EXTERNAL_DOMAINS overrides all per-directive vars below.
ALLOWED_EXTERNAL_DOMAINS=
# Per-directive overrides (optional, comma-separated):
# CSP_SCRIPT_SRC_DOMAINS=
# CSP_STYLE_SRC_DOMAINS=
# CSP_CONNECT_SRC_DOMAINS=
# CSP_FRAME_SRC_DOMAINS=
# CSP_FONT_SRC_DOMAINS=
# CSP_MEDIA_SRC_DOMAINS= (applies to both img-src and media-src)
# ──────────────────────────────────────────────
# Optional — Email (pick one approach, or skip)
@@ -0,0 +1,80 @@
import { Context, Next } from 'hono';
import type { TOrganizationApiKeyScope } from '@cio/utils/validation/organization';
import { ErrorCodes } from '@api/utils/errors';
import { organizationApiKeyHasScopes } from '@api/services/organization/automation-key';
import { getCourseOrganizationId } from '@cio/db/queries/tag';
import { courseMemberMiddleware } from './course-member';
export const courseMemberOrAutomationKeyMiddleware =
(requiredScopes: readonly TOrganizationApiKeyScope[] = []) =>
async (c: Context, next: Next) => {
try {
const automationKey = c.get('automationKey');
if (!automationKey) {
return courseMemberMiddleware(c, next);
}
if (!organizationApiKeyHasScopes(automationKey.scopes ?? [], requiredScopes)) {
return c.json(
{
success: false,
error: 'Automation key is missing required scopes',
code: ErrorCodes.FORBIDDEN
},
403
);
}
const courseId = c.req.param('courseId') || c.req.query('courseId');
if (!courseId) {
return c.json(
{
success: false,
error: 'Course ID is required',
code: 'COURSE_ID_REQUIRED'
},
400
);
}
const courseOrgId = await getCourseOrganizationId(courseId);
if (!courseOrgId) {
return c.json(
{
success: false,
error: 'Course not found',
code: ErrorCodes.COURSE_NOT_FOUND
},
404
);
}
if (courseOrgId !== automationKey.organizationId) {
return c.json(
{
success: false,
error: 'Invalid permissions',
code: ErrorCodes.UNAUTHORIZED
},
403
);
}
c.set('orgId', automationKey.organizationId);
c.set('actorId', automationKey.createdByProfileId);
return next();
} catch (error) {
console.error('Error in courseMemberOrAutomationKeyMiddleware:', error);
return c.json(
{
success: false,
error: 'Failed to verify course access',
code: 'COURSE_MEMBER_CHECK_FAILED'
},
500
);
}
};
@@ -0,0 +1,80 @@
import { Context, Next } from 'hono';
import type { TOrganizationApiKeyScope } from '@cio/utils/validation/organization';
import { ErrorCodes } from '@api/utils/errors';
import { organizationApiKeyHasScopes } from '@api/services/organization/automation-key';
import { getCourseOrganizationId } from '@cio/db/queries/tag';
import { courseTeamMemberMiddleware } from './course-team-member';
export const courseTeamMemberOrAutomationKeyMiddleware =
(requiredScopes: readonly TOrganizationApiKeyScope[] = []) =>
async (c: Context, next: Next) => {
try {
const automationKey = c.get('automationKey');
if (!automationKey) {
return courseTeamMemberMiddleware(c, next);
}
if (!organizationApiKeyHasScopes(automationKey.scopes ?? [], requiredScopes)) {
return c.json(
{
success: false,
error: 'Automation key is missing required scopes',
code: ErrorCodes.FORBIDDEN
},
403
);
}
const courseId = c.req.param('courseId') || c.req.query('courseId');
if (!courseId) {
return c.json(
{
success: false,
error: 'Course ID is required',
code: 'COURSE_ID_REQUIRED'
},
400
);
}
const courseOrgId = await getCourseOrganizationId(courseId);
if (!courseOrgId) {
return c.json(
{
success: false,
error: 'Course not found',
code: ErrorCodes.COURSE_NOT_FOUND
},
404
);
}
if (courseOrgId !== automationKey.organizationId) {
return c.json(
{
success: false,
error: 'Invalid permissions',
code: ErrorCodes.UNAUTHORIZED
},
403
);
}
c.set('orgId', automationKey.organizationId);
c.set('actorId', automationKey.createdByProfileId);
return next();
} catch (error) {
console.error('Error in courseTeamMemberOrAutomationKeyMiddleware:', error);
return c.json(
{
success: false,
error: 'Failed to verify course team access',
code: 'COURSE_TEAM_MEMBER_CHECK_FAILED'
},
500
);
}
};
@@ -0,0 +1,45 @@
import { Context, Next } from 'hono';
import type { TOrganizationApiKeyScope } from '@cio/utils/validation/organization';
import { ErrorCodes } from '@api/utils/errors';
import { organizationApiKeyHasScopes } from '@api/services/organization/automation-key';
import { orgMemberMiddleware } from './org-member';
export const orgMemberOrAutomationKeyMiddleware =
(requiredScopes: readonly TOrganizationApiKeyScope[] = []) =>
async (c: Context, next: Next) => {
try {
const automationKey = c.get('automationKey');
if (!automationKey) {
return orgMemberMiddleware(c, next);
}
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('userRole', null);
c.set('actorId', automationKey.createdByProfileId);
return next();
} catch (error) {
console.error('Error in orgMemberOrAutomationKeyMiddleware:', error);
return c.json(
{
success: false,
error: 'Failed to verify organization membership',
code: 'ORG_MEMBER_CHECK_FAILED'
},
500
);
}
};
+36 -2
View File
@@ -1,13 +1,47 @@
import { ZCourseContentDelete, ZCourseContentUpdate } from '@cio/utils/validation/course';
import { deleteCourseContent, updateCourseContent } from '@api/services/course/content';
import { ZCourseContentDelete, ZCourseContentReorder, ZCourseContentUpdate } from '@cio/utils/validation/course';
import { deleteCourseContent, reorderCourseContent, updateCourseContent } from '@api/services/course/content';
import { Hono } from '@api/utils/hono';
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
import { authMiddleware } from '@api/middlewares/auth';
import { courseTeamMemberOrAutomationKeyMiddleware } from '@api/middlewares/course-team-member-or-automation-key';
import { courseTeamMemberMiddleware } from '@api/middlewares/course-team-member';
import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
export const contentRouter = new Hono()
.put(
'/reorder',
authOrAutomationKeyMiddleware,
courseTeamMemberOrAutomationKeyMiddleware(['course:write']),
zValidator('json', ZCourseContentReorder),
async (c) => {
try {
const courseId = c.req.param('courseId')!;
const automationKey = c.get('automationKey');
const payload = c.req.valid('json');
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'reorder_course_content');
}
const result = await reorderCourseContent(courseId, payload);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'reorder_course_content', {
courseId,
sectionCount: payload.sections?.length ?? 0,
itemCount: payload.items?.length ?? 0
});
}
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to reorder course content');
}
}
)
.put('/', authMiddleware, courseTeamMemberMiddleware, zValidator('json', ZCourseContentUpdate), async (c) => {
try {
const courseId = c.req.param('courseId')!;
+44
View File
@@ -11,6 +11,7 @@ import {
ZCourseGetBySlugParam,
ZCourseGetParam,
ZCourseGetQuery,
ZCourseLandingPageUpdate,
ZCourseProgressParam,
ZCourseProgressQuery,
ZCourseUpdate,
@@ -30,9 +31,11 @@ import { getCourseTags, replaceCourseTags } from '@api/services/tag';
import { Hono } from '@api/utils/hono';
import { attendanceRouter } from '@api/routes/course/attendance';
import { authMiddleware } from '@api/middlewares/auth';
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
import { cloneCourse } from '@api/services/course/clone';
import { contentRouter } from '@api/routes/course/content';
import { courseMemberMiddleware } from '@api/middlewares/course-member';
import { courseTeamMemberOrAutomationKeyMiddleware } from '@api/middlewares/course-team-member-or-automation-key';
import { courseTeamMemberMiddleware } from '@api/middlewares/course-team-member';
import { createRateLimiter } from '@api/middlewares/rate-limiter';
import { enrollInCourse } from '@api/services/course/invite';
@@ -51,8 +54,10 @@ import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { orgMemberMiddleware } from '@api/middlewares/org-member';
import { paymentRequestRouter } from '@api/routes/course/payment-request';
import { presignRouter } from '@api/routes/course/presign';
import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage';
import { sectionRouter } from '@api/routes/course/section';
import { submissionRouter } from '@api/routes/course/submission';
import { updateCourseLandingPageService } from '@api/services/course/landing-page';
import { zValidator } from '@hono/zod-validator';
const enrollRateLimit = createRateLimiter({
@@ -233,6 +238,45 @@ export const courseRouter = new Hono()
}
}
)
/**
* PUT /course/:courseId/landing-page
* Updates landing-page-facing course fields such as copy, media, pricing, and reviews.
* Requires authentication and course team membership, or an automation key with course write scope.
*/
.put(
'/:courseId/landing-page',
authOrAutomationKeyMiddleware,
courseTeamMemberOrAutomationKeyMiddleware(['course:write']),
zValidator('param', ZCourseUpdateParam),
zValidator('json', ZCourseLandingPageUpdate),
async (c) => {
try {
const { courseId } = c.req.valid('param');
const payload = c.req.valid('json');
const automationKey = c.get('automationKey');
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'update_course_landing_page');
}
const result = await updateCourseLandingPageService(courseId, payload);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'update_course_landing_page', { courseId });
}
return c.json(
{
success: true,
data: result
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to update course landing page');
}
}
)
/**
* PUT /course/:courseId
* Updates a course
+111 -33
View File
@@ -21,24 +21,43 @@ import { getGroupMemberIdByCourseAndProfile, isCourseTeamMemberOrOrgAdmin } from
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
import { courseMemberMiddleware } from '@api/middlewares/course-member';
import { courseMemberOrAutomationKeyMiddleware } from '@api/middlewares/course-member-or-automation-key';
import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage';
import { createSubmissionService, listExerciseSubmissionsOverview } from '@api/services/submission';
import { zValidator } from '@hono/zod-validator';
export const exerciseRouter = new Hono()
// Exercise CRUD routes
.get('/', authMiddleware, courseMemberMiddleware, zValidator('query', ZExerciseListQuery), async (c) => {
try {
const courseId = c.req.param('courseId')!;
const user = c.get('user')!;
const { lessonId, sectionId } = c.req.valid('query');
const exercises = await listExercises(courseId, { lessonId, sectionId }, user.id);
.get(
'/',
authOrAutomationKeyMiddleware,
courseMemberOrAutomationKeyMiddleware(['course:exercise:read']),
zValidator('query', ZExerciseListQuery),
async (c) => {
try {
const courseId = c.req.param('courseId')!;
const user = c.get('user');
const automationKey = c.get('automationKey');
const { lessonId, sectionId } = c.req.valid('query');
return c.json({ success: true, data: exercises }, 200);
} catch (error) {
return handleError(c, error, 'Failed to list exercises');
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'list_course_exercises');
}
const exercises = await listExercises(courseId, { lessonId, sectionId }, user?.id);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'list_course_exercises', { courseId, lessonId, sectionId });
}
return c.json({ success: true, data: exercises }, 200);
} catch (error) {
return handleError(c, error, 'Failed to list exercises');
}
}
})
)
.get('/:exerciseId/submissions', authMiddleware, courseMemberMiddleware, async (c) => {
try {
const courseId = c.req.param('courseId')!;
@@ -51,28 +70,65 @@ export const exerciseRouter = new Hono()
return handleError(c, error, 'Failed to fetch exercise submissions overview');
}
})
.get('/:exerciseId', authMiddleware, courseMemberMiddleware, zValidator('param', ZExerciseGetParam), async (c) => {
try {
const { exerciseId } = c.req.valid('param');
const user = c.get('user')!;
const exercise = await getExercise(exerciseId, undefined, user.id);
.get(
'/:exerciseId',
authOrAutomationKeyMiddleware,
courseMemberOrAutomationKeyMiddleware(['course:exercise:read']),
zValidator('param', ZExerciseGetParam),
async (c) => {
try {
const { exerciseId } = c.req.valid('param');
const user = c.get('user');
const automationKey = c.get('automationKey');
return c.json({ success: true, data: exercise }, 200);
} catch (error) {
return handleError(c, error, 'Failed to fetch exercise');
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'get_course_exercise');
}
const exercise = await getExercise(exerciseId, undefined, user?.id);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'get_course_exercise', {
courseId: c.req.param('courseId')!,
exerciseId
});
}
return c.json({ success: true, data: exercise }, 200);
} catch (error) {
return handleError(c, error, 'Failed to fetch exercise');
}
}
})
.post('/', authMiddleware, courseMemberMiddleware, zValidator('json', ZExerciseCreate), async (c) => {
try {
const data = c.req.valid('json');
)
.post(
'/',
authOrAutomationKeyMiddleware,
courseMemberOrAutomationKeyMiddleware(['course:exercise:write']),
zValidator('json', ZExerciseCreate),
async (c) => {
try {
const data = c.req.valid('json');
const automationKey = c.get('automationKey');
const exercise = await createExercise(data);
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'create_course_exercise');
}
return c.json({ success: true, data: exercise }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create exercise');
const exercise = await createExercise(data);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'create_course_exercise', {
courseId: c.req.param('courseId')!,
exerciseId: exercise.id
});
}
return c.json({ success: true, data: exercise }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create exercise');
}
}
})
)
/**
* POST /course/:courseId/exercise/from-template
* Creates an exercise from a template
@@ -80,14 +136,19 @@ export const exerciseRouter = new Hono()
*/
.post(
'/from-template',
authMiddleware,
courseMemberMiddleware,
authOrAutomationKeyMiddleware,
courseMemberOrAutomationKeyMiddleware(['course:exercise:write']),
zValidator('json', ZExerciseFromTemplate),
async (c) => {
try {
const courseId = c.req.param('courseId')!;
const automationKey = c.get('automationKey');
const { lessonId, sectionId, order, templateId } = c.req.valid('json');
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'create_course_exercise_from_template');
}
// Fetch template from database
const template = await fetchTemplateById(templateId);
if (!template) {
@@ -100,6 +161,14 @@ export const exerciseRouter = new Hono()
const exercise = await createExerciseFromTemplate(courseId, lessonId, sectionId, order, template);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'create_course_exercise_from_template', {
courseId,
exerciseId: exercise.id,
templateId
});
}
return c.json({ success: true, data: exercise }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create exercise from template');
@@ -108,8 +177,8 @@ export const exerciseRouter = new Hono()
)
.put(
'/:exerciseId',
authMiddleware,
courseMemberMiddleware,
authOrAutomationKeyMiddleware,
courseMemberOrAutomationKeyMiddleware(['course:exercise:write']),
zValidator('param', ZExerciseGetParam),
zValidator('json', ZExerciseUpdate),
async (c) => {
@@ -117,9 +186,14 @@ export const exerciseRouter = new Hono()
const { exerciseId } = c.req.valid('param');
const data = c.req.valid('json');
const courseId = c.req.param('courseId')!;
const user = c.get('user')!;
const user = c.get('user');
const automationKey = c.get('automationKey');
if (data.isUnlocked !== undefined) {
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'update_course_exercise');
}
if (!automationKey && user && data.isUnlocked !== undefined) {
const isAuthorized = await isCourseTeamMemberOrOrgAdmin(courseId, user.id);
if (!isAuthorized) {
@@ -136,6 +210,10 @@ export const exerciseRouter = new Hono()
const exercise = await updateExerciseService(exerciseId, data);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'update_course_exercise', { courseId, exerciseId });
}
return c.json({ success: true, data: exercise }, 200);
} catch (error) {
return handleError(c, error, 'Failed to update exercise');
@@ -1,5 +1,6 @@
import {
ZCreateOrganizationApiKey,
ZOrganizationAutomationUsageQuery,
ZListOrganizationApiKeysQuery,
ZOrganizationApiKeyParam
} from '@cio/utils/validation/organization';
@@ -9,6 +10,7 @@ import {
revokeOrganizationApiKeyService,
rotateOrganizationApiKeyService
} from '@api/services/organization/automation-key';
import { getOrganizationAutomationUsageSummaryService } from '@api/services/organization/automation-usage';
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
@@ -17,6 +19,23 @@ import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { zValidator } from '@hono/zod-validator';
export const automationRouter = new Hono()
.get(
'/usage',
authMiddleware,
orgAdminMiddleware,
zValidator('query', ZOrganizationAutomationUsageQuery),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const query = c.req.valid('query');
const usage = await getOrganizationAutomationUsageSummaryService(orgId, query.type);
return c.json({ success: true, data: usage }, 200);
} catch (error) {
return handleError(c, error, 'Failed to load organization automation usage');
}
}
)
.get('/keys', authMiddleware, orgAdminMiddleware, zValidator('query', ZListOrganizationApiKeysQuery), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
@@ -1,15 +1,24 @@
import { ZAutomationDraftTagAssignment, ZAutomationDraftTagParam } from '@cio/utils/validation/tag';
import {
ZCourseImportCourseParam,
ZCourseImportDraftCreate,
ZCourseImportDraftCreateFromCourse,
ZCourseImportDraftGetParam,
ZCourseImportDraftPublish,
ZCourseImportDraftPublishToCourse,
ZCourseImportDraftUpdate
} from '@cio/utils/validation/course-import';
import {
createCourseImportDraftFromCourseService,
createCourseImportDraftService,
getCourseImportDraftService,
getCourseImportStructureService,
publishCourseImportDraftService,
publishCourseImportDraftToExistingCourseService,
updateCourseImportDraftTagsService,
updateCourseImportDraftService
} from '@api/services/course-import/course-import';
import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage';
import { Hono } from '@api/utils/hono';
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
@@ -18,6 +27,33 @@ import { orgAdminOrAutomationKeyMiddleware } from '@api/middlewares/org-admin-or
import { zValidator } from '@hono/zod-validator';
export const courseImportRouter = new Hono()
.get(
'/courses/:courseId/structure',
authOrAutomationKeyMiddleware,
orgAdminOrAutomationKeyMiddleware(['course_import:draft:read']),
zValidator('param', ZCourseImportCourseParam),
async (c) => {
try {
const orgId = c.get('orgId')!;
const automationKey = c.get('automationKey');
const { courseId } = c.req.valid('param');
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'get_course_structure');
}
const structure = await getCourseImportStructureService(orgId, courseId);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'get_course_structure', { courseId });
}
return c.json({ success: true, data: structure }, 200);
} catch (error) {
return handleError(c, error, 'Failed to fetch course structure');
}
}
)
.post(
'/drafts',
authOrAutomationKeyMiddleware,
@@ -27,16 +63,56 @@ export const courseImportRouter = new Hono()
try {
const orgId = c.get('orgId')!;
const actorId = c.get('actorId')!;
const automationKey = c.get('automationKey');
const payload = c.req.valid('json');
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'create_course_draft');
}
const draft = await createCourseImportDraftService(orgId, actorId, payload);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'create_course_draft', { draftId: draft.id });
}
return c.json({ success: true, data: draft }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create course import draft');
}
}
)
.post(
'/drafts/from-course',
authOrAutomationKeyMiddleware,
orgAdminOrAutomationKeyMiddleware(['course_import:draft:create']),
zValidator('json', ZCourseImportDraftCreateFromCourse),
async (c) => {
try {
const orgId = c.get('orgId')!;
const actorId = c.get('actorId')!;
const automationKey = c.get('automationKey');
const payload = c.req.valid('json');
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'create_course_draft_from_course');
}
const draft = await createCourseImportDraftFromCourseService(orgId, actorId, payload);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'create_course_draft_from_course', {
draftId: draft.id,
courseId: payload.courseId
});
}
return c.json({ success: true, data: draft }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create course draft from existing course');
}
}
)
.get(
'/drafts/:draftId',
authOrAutomationKeyMiddleware,
@@ -45,16 +121,54 @@ export const courseImportRouter = new Hono()
async (c) => {
try {
const orgId = c.get('orgId')!;
const automationKey = c.get('automationKey');
const { draftId } = c.req.valid('param');
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'get_course_draft');
}
const draft = await getCourseImportDraftService(orgId, draftId);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'get_course_draft', { draftId });
}
return c.json({ success: true, data: draft }, 200);
} catch (error) {
return handleError(c, error, 'Failed to fetch course import draft');
}
}
)
.put(
'/drafts/:draftId/tags',
authOrAutomationKeyMiddleware,
orgAdminOrAutomationKeyMiddleware(['course_import:draft:update']),
zValidator('param', ZAutomationDraftTagParam),
zValidator('json', ZAutomationDraftTagAssignment),
async (c) => {
try {
const orgId = c.get('orgId')!;
const automationKey = c.get('automationKey');
const { draftId } = c.req.valid('param');
const payload = c.req.valid('json');
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'tag_course_draft');
}
const draft = await updateCourseImportDraftTagsService(orgId, draftId, payload.tagNames, payload.mode);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'tag_course_draft', { draftId });
}
return c.json({ success: true, data: draft }, 200);
} catch (error) {
return handleError(c, error, 'Failed to update course draft tags');
}
}
)
.put(
'/drafts/:draftId',
authOrAutomationKeyMiddleware,
@@ -64,11 +178,20 @@ export const courseImportRouter = new Hono()
async (c) => {
try {
const orgId = c.get('orgId')!;
const automationKey = c.get('automationKey');
const { draftId } = c.req.valid('param');
const payload = c.req.valid('json');
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'update_course_draft');
}
const draft = await updateCourseImportDraftService(orgId, draftId, payload);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'update_course_draft', { draftId });
}
return c.json({ success: true, data: draft }, 200);
} catch (error) {
return handleError(c, error, 'Failed to update course import draft');
@@ -85,14 +208,58 @@ export const courseImportRouter = new Hono()
try {
const orgId = c.get('orgId')!;
const actorId = c.get('actorId')!;
const automationKey = c.get('automationKey');
const { draftId } = c.req.valid('param');
const payload = c.req.valid('json');
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'publish_course_draft');
}
const result = await publishCourseImportDraftService(orgId, actorId, draftId, payload);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'publish_course_draft', {
draftId,
courseId: result.courseId
});
}
return c.json({ success: true, data: result }, 201);
} catch (error) {
return handleError(c, error, 'Failed to publish course import draft');
}
}
)
.post(
'/drafts/:draftId/publish-existing-course',
authOrAutomationKeyMiddleware,
orgAdminOrAutomationKeyMiddleware(['course_import:draft:publish']),
zValidator('param', ZCourseImportDraftGetParam),
zValidator('json', ZCourseImportDraftPublishToCourse),
async (c) => {
try {
const orgId = c.get('orgId')!;
const automationKey = c.get('automationKey');
const { draftId } = c.req.valid('param');
const payload = c.req.valid('json');
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'publish_course_draft_to_existing_course');
}
const result = await publishCourseImportDraftToExistingCourseService(orgId, draftId, payload);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'publish_course_draft_to_existing_course', {
draftId,
courseId: payload.courseId
});
}
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to publish course draft to existing course');
}
}
);
@@ -37,15 +37,19 @@ 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 { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key';
import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage';
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';
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { orgMemberMiddleware } from '@api/middlewares/org-member';
import { orgMemberOrAutomationKeyMiddleware } from '@api/middlewares/org-member-or-automation-key';
import { quizRouter } from '@api/routes/organization/quiz';
import { tagsRouter } from '@api/routes/organization/tags';
import { ROLE } from '@cio/utils/constants';
import { zValidator } from '@hono/zod-validator';
export const organizationRouter = new Hono()
@@ -287,17 +291,18 @@ export const organizationRouter = new Hono()
*/
.get(
'/courses',
authMiddleware,
orgMemberMiddleware,
authOrAutomationKeyMiddleware,
orgMemberOrAutomationKeyMiddleware(['course:read']),
zValidator('query', ZGetOrganizationCoursesQuery),
async (c) => {
try {
const user = c.get('user')!;
const user = c.get('user');
const orgId = c.get('orgId');
const userRole = c.get('userRole');
const automationKey = c.get('automationKey');
const { tags } = c.req.valid('query');
if (!orgId || userRole === null) {
if (!orgId) {
return c.json(
{
success: false,
@@ -308,22 +313,25 @@ export const organizationRouter = new Hono()
);
}
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'list_org_courses');
}
const tagSlugs = tags
?.split(',')
.map((value) => value.trim())
.filter(Boolean);
console.log('user', user);
console.log('userRole', userRole);
console.log('orgId', orgId);
console.log('tagSlugs', tagSlugs);
const result = await getOrganizationCourses(
orgId,
user.id,
userRole,
user?.id ?? automationKey?.createdByProfileId ?? '',
userRole ?? ROLE.ADMIN,
tagSlugs && tagSlugs.length > 0 ? tagSlugs : undefined
);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'list_org_courses', { tags: tagSlugs });
}
return c.json(
{
success: true,
+38
View File
@@ -1,4 +1,5 @@
import {
ZAutomationCourseTagAssignment,
ZTagCreate,
ZTagGroupCreate,
ZTagGroupParam,
@@ -7,6 +8,7 @@ import {
ZTagUpdate
} from '@cio/utils/validation/tag';
import {
assignTagsToCoursesByName,
createOrganizationTag,
createOrganizationTagGroup,
getPublicOrganizationTagGroups,
@@ -18,8 +20,11 @@ import { ZGetCoursesBySiteName } from '@cio/utils/validation/organization';
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
import { handleError } from '@api/utils/errors';
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { orgAdminOrAutomationKeyMiddleware } from '@api/middlewares/org-admin-or-automation-key';
import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage';
import { zValidator } from '@hono/zod-validator';
export const tagsRouter = new Hono()
@@ -72,6 +77,39 @@ export const tagsRouter = new Hono()
return handleError(c, error, 'Failed to create tag group');
}
})
.put(
'/courses/assign',
authOrAutomationKeyMiddleware,
orgAdminOrAutomationKeyMiddleware(['course:tag:write']),
zValidator('json', ZAutomationCourseTagAssignment),
async (c) => {
try {
const orgId = c.get('orgId')!;
const automationKey = c.get('automationKey');
const data = c.req.valid('json');
if (automationKey?.type === 'mcp') {
await assertMcpAutomationUsageAllowed(automationKey, 'tag_courses');
}
const result = await assignTagsToCoursesByName(orgId, data);
if (automationKey?.type === 'mcp') {
await recordMcpAutomationUsage(automationKey, 'tag_courses', { courseIds: data.courseIds });
}
return c.json(
{
success: true,
data: result
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to assign course tags');
}
}
)
.put(
'/groups/:groupId',
authMiddleware,
@@ -1,22 +1,37 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { env } from '@api/config/env';
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { createCourse, updateCourse } from '@api/services/course/course';
import { createCourseSection, listCourseSections, updateCourseSectionService } from '@api/services/course/section';
import { createLesson, listLessons, updateLessonService } from '@api/services/lesson';
import { upsertLessonLanguageService, updateLessonLanguageService } from '@api/services/lesson-language';
import {
createCourseImportDraft,
getCourseImportDraftById,
getCourseImportDraftByIdempotencyKey,
updateCourseImportDraft
} from '@cio/db/queries/course-import';
import type { TCourseImportDraft, TLocale } from '@db/types';
import type {
TCourseImportDraftCreate,
TCourseImportDraftPayload,
TCourseImportDraftPublish,
TCourseImportDraftUpdate
import { getCourseById } from '@cio/db/queries/course';
import { getLessonLanguagesByLessonIds } from '@cio/db/queries/lesson';
import { getOrganizationById } from '@cio/db/queries/organization';
import { getCourseTagsForOrganization } from '@cio/db/queries/tag';
import type { TCourse, TCourseImportDraft, TLessonLanguage, TLocale, TOrganization } from '@db/types';
import {
type TCourseImportDraftCreate,
type TCourseImportDraftCreateFromCourse,
type TCourseImportDraftPayload,
type TCourseImportDraftPublish,
type TCourseImportDraftPublishToCourse,
type TCourseImportDraftUpdate,
ZCourseImportDraftPayload,
ZCourseImportWarning
} 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';
import { getCourseOrganizationId } from '@cio/db/queries/tag';
import { assignCourseTagsByName } from '@api/services/tag';
import { db } from '@cio/db/drizzle';
import * as schema from '@cio/db/schema';
import { generateSlug } from '@cio/utils/functions';
import { eq } from 'drizzle-orm';
type DraftSummary = {
sectionCount: number;
@@ -25,6 +40,30 @@ type DraftSummary = {
warningCount: number;
};
type CourseStructureSnapshot = {
courseId: string;
draft: TCourseImportDraftPayload;
};
type PublishDraftResult = {
courseId: string;
courseUrl: string;
bannerImageUrl: string | null;
tagNames: string[];
createdSections: number;
createdLessons: number;
localeCount: number;
};
type PublishDraftToExistingCourseResult = PublishDraftResult & {
updatedSections: number;
updatedLessons: number;
createdLessonLanguages: number;
updatedLessonLanguages: number;
untouchedSections: number;
untouchedLessons: number;
};
function buildDraftSummary(draft: TCourseImportDraftPayload): DraftSummary {
return {
sectionCount: draft.sections.length,
@@ -34,10 +73,314 @@ function buildDraftSummary(draft: TCourseImportDraftPayload): DraftSummary {
};
}
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 [course] = await getCourseById(courseId);
if (course?.slug) {
return course.slug;
}
const baseSlug = generateSlug(title, { fallback: 'course' });
const uniqueSlug = await generateUniqueCourseSlug(baseSlug);
await updateCourse(courseId, { slug: uniqueSlug });
return uniqueSlug;
}
function buildCourseBaseUrl(organization: TOrganization) {
if (organization.customDomain && organization.isCustomDomainVerified) {
return `https://${organization.customDomain}`;
}
return getDashboardBaseUrl(organization.siteName ?? undefined);
}
async function resolveCourseUrl(organizationId: string, courseId: string, title: string) {
const organization = await getOrganizationById(organizationId);
if (!organization) {
throw new AppError('Organization not found', ErrorCodes.ORG_NOT_FOUND, 404);
}
const slug = await ensureCourseSlug(courseId, title);
return `${buildCourseBaseUrl(organization)}/course/${encodeURIComponent(slug)}`;
}
async function resolveUnsplashBannerImage(courseTitle: string, query?: string) {
if (!env.UNSPLASH_API_KEY) {
return null;
}
const searchQuery = (query?.trim() || courseTitle.trim() || 'education').slice(0, 120);
const response = await fetch(
`https://api.unsplash.com/search/photos?page=1&per_page=15&auto=format&fit=crop&w=2970&q=80&client_id=${env.UNSPLASH_API_KEY}&query=${encodeURIComponent(searchQuery)}`,
{ method: 'GET' }
);
if (!response.ok) {
return null;
}
const data = (await response.json()) as {
results?: Array<{
urls?: {
regular?: string;
};
}>;
};
const photos = data.results ?? [];
if (photos.length === 0) {
return null;
}
const photo = photos[Math.floor(Math.random() * photos.length)];
return photo?.urls?.regular ?? null;
}
async function resolvePublishBannerImage(
title: string,
overrides: Pick<TCourseImportDraftPublish, 'bannerImageQuery' | 'bannerImageUrl' | 'generateBannerImage'>
) {
if (overrides.bannerImageUrl) {
return overrides.bannerImageUrl;
}
if (!overrides.generateBannerImage && !overrides.bannerImageQuery) {
return null;
}
try {
return await resolveUnsplashBannerImage(title, overrides.bannerImageQuery);
} catch (error) {
console.error('resolvePublishBannerImage error:', error);
return null;
}
}
async function maybeApplyCourseBannerImage(courseId: string, imageUrl: string | null) {
if (!imageUrl) {
return null;
}
await updateCourse(courseId, {
logo: imageUrl,
bannerImage: imageUrl
});
return imageUrl;
}
function normalizeDraftTagNames(tagNames: string[]) {
return Array.from(new Map(tagNames.map((tag) => [tag.trim().toLowerCase(), tag.trim()])).values()).filter(Boolean);
}
function parseStoredDraft(record: TCourseImportDraft): TCourseImportDraftPayload {
return ZCourseImportDraftPayload.parse(record.draft);
}
async function assertCourseBelongsToOrganization(orgId: string, courseId: string) {
const courseOrgId = await getCourseOrganizationId(courseId);
if (!courseOrgId) {
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
}
if (courseOrgId !== orgId) {
throw new AppError('Invalid permissions', ErrorCodes.UNAUTHORIZED, 403);
}
}
async function getCourseRecord(orgId: string, courseId: string): Promise<TCourse> {
await assertCourseBelongsToOrganization(orgId, courseId);
const [course] = await getCourseById(courseId);
if (!course) {
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
}
return course;
}
function inferDraftLocale(languages: TLessonLanguage[]): TLocale {
const locale = languages[0]?.locale;
return (locale ?? 'en') as TLocale;
}
function getPrimaryLessonContent(
lessonExternalId: string,
lessonLanguages: TCourseImportDraftPayload['lessonLanguages'],
preferredLocale: TLocale
) {
const localized = lessonLanguages.find(
(lessonLanguage) =>
lessonLanguage.lessonExternalId === lessonExternalId && lessonLanguage.locale === preferredLocale
);
if (localized) {
return localized.content;
}
return lessonLanguages.find((lessonLanguage) => lessonLanguage.lessonExternalId === lessonExternalId)?.content;
}
async function buildCourseStructureSnapshot(orgId: string, courseId: string): Promise<CourseStructureSnapshot> {
const course = await getCourseRecord(orgId, courseId);
const [sections, lessons, tags] = await Promise.all([
listCourseSections(courseId),
listLessons(courseId),
getCourseTagsForOrganization(orgId, courseId)
]);
if (lessons.length === 0) {
throw new AppError(
'Course must contain at least one lesson before it can be imported into a draft',
ErrorCodes.VALIDATION_ERROR,
400
);
}
const lessonIds = lessons.map((lesson) => lesson.id);
const lessonLanguages = await getLessonLanguagesByLessonIds(lessonIds);
const fallbackLocale = inferDraftLocale(lessonLanguages);
const warnings: TCourseImportDraftPayload['warnings'] = [];
const normalizedSections = [...sections]
.sort((a, b) => Number(a.order ?? 0) - Number(b.order ?? 0))
.map((section) => ({
externalId: section.id,
title: section.title ?? 'Untitled Section',
order: Number(section.order ?? 0)
}));
let sectionReferenceMap = new Map(normalizedSections.map((section) => [section.externalId, section.externalId]));
if (normalizedSections.length === 0 || lessons.some((lesson) => !lesson.sectionId)) {
const syntheticSectionId = `synthetic:${courseId}:ungrouped`;
normalizedSections.push({
externalId: syntheticSectionId,
title: 'Ungrouped',
order: normalizedSections.length
});
sectionReferenceMap = new Map(normalizedSections.map((section) => [section.externalId, section.externalId]));
warnings.push(
ZCourseImportWarning.parse({
code: 'UNGROUPED_CONTENT_NORMALIZED',
message: 'Some lessons were ungrouped. A synthetic section was added to the draft.',
severity: 'warning'
})
);
}
const normalizedLessons = [...lessons]
.sort((a, b) => Number(a.order ?? 0) - Number(b.order ?? 0))
.map((lesson) => ({
externalId: lesson.id,
sectionExternalId:
lesson.sectionId && sectionReferenceMap.has(lesson.sectionId)
? lesson.sectionId
: normalizedSections[normalizedSections.length - 1]!.externalId,
title: lesson.title,
order: Number(lesson.order ?? 0),
isUnlocked: lesson.isUnlocked ?? undefined,
public: lesson.public ?? undefined
}));
const normalizedLessonLanguages: TCourseImportDraftPayload['lessonLanguages'] = [];
for (const lesson of lessons) {
const localized = lessonLanguages.filter((lessonLanguage) => lessonLanguage.lessonId === lesson.id);
if (localized.length > 0) {
const validLocalized = localized
.map((lessonLanguage) => ({
lessonExternalId: lesson.id,
locale: lessonLanguage.locale as TLocale,
content: lessonLanguage.content?.trim() ?? ''
}))
.filter((lessonLanguage) => lessonLanguage.content.length > 0);
if (validLocalized.length > 0) {
normalizedLessonLanguages.push(...validLocalized);
continue;
}
warnings.push(
ZCourseImportWarning.parse({
code: 'EMPTY_LOCALE_CONTENT_SKIPPED',
message: `Lesson "${lesson.title}" had empty localized content and required a fallback.`,
severity: 'warning'
})
);
}
if (lesson.note?.trim()) {
normalizedLessonLanguages.push({
lessonExternalId: lesson.id,
locale: fallbackLocale,
content: lesson.note
});
warnings.push(
ZCourseImportWarning.parse({
code: 'LEGACY_NOTE_FALLBACK',
message: `Lesson "${lesson.title}" used legacy note content because no lesson_language rows were found.`,
severity: 'info'
})
);
}
}
if (normalizedLessonLanguages.length === 0) {
throw new AppError(
'Course lessons must contain note or localized content before they can be imported into a draft',
ErrorCodes.VALIDATION_ERROR,
400
);
}
const draft = ZCourseImportDraftPayload.parse({
course: {
title: course.title,
description: course.description,
type: course.type,
locale: fallbackLocale,
metadata: course.metadata
},
tags: tags.map((tag) => tag.name),
sections: normalizedSections,
lessons: normalizedLessons,
lessonLanguages: normalizedLessonLanguages,
sourceReferences: [
{
type: 'course',
label: course.title
}
],
warnings
});
return {
courseId,
draft
};
}
export async function createCourseImportDraftService(orgId: string, profileId: string, data: TCourseImportDraftCreate) {
try {
if (data.idempotencyKey) {
@@ -76,6 +419,48 @@ export async function createCourseImportDraftService(orgId: string, profileId: s
}
}
export async function createCourseImportDraftFromCourseService(
orgId: string,
profileId: string,
data: TCourseImportDraftCreateFromCourse
) {
const snapshot = await buildCourseStructureSnapshot(orgId, data.courseId);
return createCourseImportDraftService(orgId, profileId, {
sourceType: 'course',
idempotencyKey: data.idempotencyKey,
summary: {
sourceCourseId: data.courseId,
...(data.summary ?? {})
},
sourceArtifacts: [
{
type: 'course',
courseId: data.courseId,
label: snapshot.draft.course.title
},
...(data.sourceArtifacts ?? [])
],
draft: snapshot.draft
});
}
export async function getCourseImportStructureService(orgId: string, courseId: string) {
try {
return await buildCourseStructureSnapshot(orgId, courseId);
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to fetch course structure',
ErrorCodes.COURSE_IMPORT_FETCH_FAILED,
500
);
}
}
export async function getCourseImportDraftService(orgId: string, draftId: string) {
try {
const draft = await getCourseImportDraftById(orgId, draftId);
@@ -138,18 +523,52 @@ export async function updateCourseImportDraftService(orgId: string, draftId: str
}
}
export async function updateCourseImportDraftTagsService(
orgId: string,
draftId: string,
tagNames: string[],
mode: 'merge' | 'replace'
) {
const existing = await getCourseImportDraftService(orgId, draftId);
if (existing.status === 'PUBLISHED') {
throw new AppError('Published drafts cannot be edited', ErrorCodes.FORBIDDEN, 403);
}
const draft = parseStoredDraft(existing);
const nextTags =
mode === 'replace' ? normalizeDraftTagNames(tagNames) : normalizeDraftTagNames([...draft.tags, ...tagNames]);
return updateCourseImportDraftService(orgId, draftId, {
draft: {
...draft,
tags: nextTags
}
});
}
export async function publishCourseImportDraftService(
orgId: string,
profileId: string,
draftId: string,
overrides: TCourseImportDraftPublish
) {
): Promise<PublishDraftResult> {
try {
const existing = await getCourseImportDraftService(orgId, draftId);
if (existing.status === 'PUBLISHED' && existing.publishedCourseId) {
const [publishedCourse] = await getCourseById(existing.publishedCourseId);
const courseUrl = await resolveCourseUrl(
orgId,
existing.publishedCourseId,
publishedCourse?.title ?? existing.title
);
const draft = parseStoredDraft(existing);
return {
courseId: existing.publishedCourseId,
courseUrl,
bannerImageUrl: publishedCourse?.logo ?? null,
tagNames: normalizeDraftTagNames(draft.tags),
createdSections: 0,
createdLessons: 0,
localeCount: 0
@@ -182,6 +601,9 @@ export async function publishCourseImportDraftService(
await updateCourse(courseId, { metadata: courseMetadata });
}
const bannerImageUrl = await resolvePublishBannerImage(courseTitle, overrides);
await maybeApplyCourseBannerImage(courseId, bannerImageUrl);
const sectionIdMap = new Map<string, string>();
for (const section of [...draft.sections].sort((a, b) => a.order - b.order)) {
const createdSection = await createCourseSection(courseId, {
@@ -203,6 +625,7 @@ export async function publishCourseImportDraftService(
const createdLesson = await createLesson(courseId, {
courseId,
title: lesson.title,
note: getPrimaryLessonContent(lesson.externalId, draft.lessonLanguages, draft.course.locale as TLocale),
sectionId,
order: lesson.order,
isUnlocked: lesson.isUnlocked,
@@ -224,6 +647,16 @@ export async function publishCourseImportDraftService(
});
}
const appliedTagNames = normalizeDraftTagNames(draft.tags);
if (appliedTagNames.length > 0) {
await assignCourseTagsByName(orgId, courseId, {
tagNames: appliedTagNames,
mode: 'replace'
});
}
const courseUrl = await resolveCourseUrl(orgId, courseId, courseTitle);
await updateCourseImportDraft(orgId, draftId, {
status: 'PUBLISHED',
publishedCourseId: courseId,
@@ -236,6 +669,9 @@ export async function publishCourseImportDraftService(
return {
courseId,
courseUrl,
bannerImageUrl,
tagNames: appliedTagNames,
createdSections: sectionIdMap.size,
createdLessons: lessonIdMap.size,
localeCount: new Set(draft.lessonLanguages.map((item) => item.locale)).size
@@ -252,3 +688,184 @@ export async function publishCourseImportDraftService(
);
}
}
export async function publishCourseImportDraftToExistingCourseService(
orgId: string,
draftId: string,
overrides: TCourseImportDraftPublishToCourse
): Promise<PublishDraftToExistingCourseResult> {
try {
const existingDraft = await getCourseImportDraftService(orgId, draftId);
const draft = parseStoredDraft(existingDraft);
const course = await getCourseRecord(orgId, overrides.courseId);
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 preferredLocale = draft.course.locale as TLocale;
const bannerImageUrl = await resolvePublishBannerImage(courseTitle, overrides);
await updateCourse(course.id, {
title: courseTitle,
description: courseDescription,
type: courseType,
metadata: courseMetadata
});
await maybeApplyCourseBannerImage(course.id, bannerImageUrl);
const [existingSections, existingLessons] = await Promise.all([
listCourseSections(course.id),
listLessons(course.id)
]);
const existingSectionIds = new Set(existingSections.map((section) => section.id));
const existingLessonIds = new Set(existingLessons.map((lesson) => lesson.id));
const existingLessonLanguages = await getLessonLanguagesByLessonIds(existingLessons.map((lesson) => lesson.id));
const sectionIdMap = new Map<string, string>();
let createdSections = 0;
let updatedSections = 0;
for (const section of [...draft.sections].sort((a, b) => a.order - b.order)) {
if (existingSectionIds.has(section.externalId)) {
const updatedSection = await updateCourseSectionService(section.externalId, {
title: section.title,
order: section.order
});
sectionIdMap.set(section.externalId, updatedSection.id);
updatedSections += 1;
continue;
}
const createdSection = await createCourseSection(course.id, {
courseId: course.id,
title: section.title,
order: section.order
});
sectionIdMap.set(section.externalId, createdSection.id);
createdSections += 1;
}
const lessonIdMap = new Map<string, string>();
let createdLessons = 0;
let updatedLessons = 0;
for (const lesson of [...draft.lessons].sort((a, b) => a.order - b.order)) {
const sectionId = sectionIdMap.get(lesson.sectionExternalId);
if (!sectionId) {
throw new AppError(`Lesson "${lesson.title}" references an unknown section`, ErrorCodes.VALIDATION_ERROR, 400);
}
const note = getPrimaryLessonContent(lesson.externalId, draft.lessonLanguages, preferredLocale);
if (existingLessonIds.has(lesson.externalId)) {
const updatedLesson = await updateLessonService(lesson.externalId, {
title: lesson.title,
note,
sectionId,
order: lesson.order,
isUnlocked: lesson.isUnlocked,
public: lesson.public
});
lessonIdMap.set(lesson.externalId, updatedLesson.id);
updatedLessons += 1;
continue;
}
const createdLesson = await createLesson(course.id, {
courseId: course.id,
title: lesson.title,
note,
sectionId,
order: lesson.order,
isUnlocked: lesson.isUnlocked,
public: lesson.public
});
lessonIdMap.set(lesson.externalId, createdLesson.id);
createdLessons += 1;
}
const existingLessonLanguageMap = new Map(
existingLessonLanguages.map((lessonLanguage) => [
`${lessonLanguage.lessonId}:${lessonLanguage.locale}`,
lessonLanguage
])
);
let createdLessonLanguages = 0;
let updatedLessonLanguages = 0;
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);
}
const languageKey = `${lessonId}:${lessonLanguage.locale}`;
if (existingLessonLanguageMap.has(languageKey)) {
await updateLessonLanguageService(lessonId, lessonLanguage.locale as TLocale, {
content: lessonLanguage.content
});
updatedLessonLanguages += 1;
continue;
}
await upsertLessonLanguageService(lessonId, {
locale: lessonLanguage.locale as TLocale,
content: lessonLanguage.content
});
createdLessonLanguages += 1;
}
const appliedTagNames = normalizeDraftTagNames(draft.tags);
if (appliedTagNames.length > 0) {
await assignCourseTagsByName(orgId, course.id, {
tagNames: appliedTagNames,
mode: 'merge'
});
}
const referencedSectionIds = new Set([...sectionIdMap.values()].filter((id) => existingSectionIds.has(id)));
const referencedLessonIds = new Set([...lessonIdMap.values()].filter((id) => existingLessonIds.has(id)));
const courseUrl = await resolveCourseUrl(orgId, course.id, courseTitle);
await updateCourseImportDraft(orgId, draftId, {
status: 'PUBLISHED',
publishedCourseId: course.id,
summary: {
...(existingDraft.summary ?? {}),
...buildDraftSummary(draft),
publishedCourseId: course.id,
updatedSections,
updatedLessons,
createdLessonLanguages,
updatedLessonLanguages
}
});
return {
courseId: course.id,
courseUrl,
bannerImageUrl,
tagNames: appliedTagNames,
createdSections,
updatedSections,
createdLessons,
updatedLessons,
createdLessonLanguages,
updatedLessonLanguages,
untouchedSections: existingSections.length - referencedSectionIds.size,
untouchedLessons: existingLessons.length - referencedLessonIds.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 to existing course',
ErrorCodes.COURSE_IMPORT_PUBLISH_FAILED,
500
);
}
}
+218 -115
View File
@@ -1,13 +1,21 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { ContentType } from '@cio/utils/constants';
import { db } from '@cio/db/drizzle';
import { db, type DbOrTxClient } from '@cio/db/drizzle';
import { sql, inArray } from 'drizzle-orm';
import { lesson, exercise } from '@cio/db/schema';
import { courseSection, lesson, exercise } from '@cio/db/schema';
import { getCourseContentItems, type CourseContentItemRow } from '@cio/db/queries/course/content';
import { deleteLesson } from '@cio/db/queries/lesson';
import { deleteCourseSection, getCourseSectionById } from '@cio/db/queries/course';
import { deleteCourseSection, getCourseSectionById, getCourseSectionsByCourseId } from '@cio/db/queries/course';
import { deleteExercise } from '@cio/db/queries/exercise/exercise';
import type { TCourseContentDelete, TCourseContentUpdate } from '@cio/utils/validation/course';
import type { TCourseContentDelete, TCourseContentReorder, TCourseContentUpdate } from '@cio/utils/validation/course';
type CourseContentReorderResult = {
courseId: string;
updatedSections: number;
updatedItems: number;
updatedLessons: number;
updatedExercises: number;
};
function buildUpdatePayload(item: TCourseContentUpdate['items'][number]) {
const payload: Record<string, unknown> = {};
@@ -64,122 +72,175 @@ async function assertCourseContentItems(courseId: string, items: TCourseContentU
});
}
async function assertCourseSections(courseId: string, sections: NonNullable<TCourseContentReorder['sections']>) {
const existingSections = await getCourseSectionsByCourseId(courseId);
const sectionIds = new Set(existingSections.map((section) => section.id));
sections.forEach((section) => {
if (!sectionIds.has(section.id)) {
throw new AppError('Course section not found', ErrorCodes.COURSE_SECTION_NOT_FOUND, 404);
}
});
}
async function applyCourseContentUpdates(tx: DbOrTxClient, items: TCourseContentUpdate['items']) {
const updates = items
.map((item) => ({
item,
payload: buildUpdatePayload(item)
}))
.filter(({ payload }) => Object.keys(payload).length > 0);
if (updates.length === 0) {
return {
updatedItems: 0,
updatedLessons: 0,
updatedExercises: 0
};
}
const updatedAt = new Date().toISOString();
const lessonUpdates = updates.filter(({ item }) => item.type === ContentType.Lesson);
const exerciseUpdates = updates.filter(({ item }) => item.type === ContentType.Exercise);
if (lessonUpdates.length > 0) {
const lessonIds = lessonUpdates.map(({ item }) => item.id);
const lessonOrderUpdates = lessonUpdates
.filter(({ payload }) => payload.order !== undefined)
.map(({ item, payload }) => ({ id: item.id, value: payload.order }));
const lessonSectionUpdates = lessonUpdates
.filter(({ payload }) => payload.sectionId !== undefined)
.map(({ item, payload }) => ({ id: item.id, value: payload.sectionId }));
const lessonUnlockUpdates = lessonUpdates
.filter(({ payload }) => payload.isUnlocked !== undefined)
.map(({ item, payload }) => ({ id: item.id, value: payload.isUnlocked }));
const lessonSet: Record<string, unknown> = {
updatedAt
};
if (lessonOrderUpdates.length > 0) {
lessonSet.order = sql`case ${lesson.id} ${sql.join(
lessonOrderUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
sql` `
)} else ${lesson.order} end`;
}
if (lessonSectionUpdates.length > 0) {
lessonSet.sectionId = sql`case ${lesson.id} ${sql.join(
lessonSectionUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
sql` `
)} else ${lesson.sectionId} end`;
}
if (lessonUnlockUpdates.length > 0) {
lessonSet.isUnlocked = sql`case ${lesson.id} ${sql.join(
lessonUnlockUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
sql` `
)} else ${lesson.isUnlocked} end`;
}
const updatedLessons = await tx
.update(lesson)
.set(lessonSet)
.where(inArray(lesson.id, lessonIds))
.returning({ id: lesson.id });
if (updatedLessons.length !== lessonIds.length) {
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
}
}
if (exerciseUpdates.length > 0) {
const exerciseIds = exerciseUpdates.map(({ item }) => item.id);
const exerciseOrderUpdates = exerciseUpdates
.filter(({ payload }) => payload.order !== undefined)
.map(({ item, payload }) => ({ id: item.id, value: payload.order }));
const exerciseSectionUpdates = exerciseUpdates
.filter(({ payload }) => payload.sectionId !== undefined)
.map(({ item, payload }) => ({ id: item.id, value: payload.sectionId }));
const exerciseUnlockUpdates = exerciseUpdates
.filter(({ payload }) => payload.isUnlocked !== undefined)
.map(({ item, payload }) => ({ id: item.id, value: payload.isUnlocked }));
const exerciseSet: Record<string, unknown> = {
updatedAt
};
if (exerciseOrderUpdates.length > 0) {
exerciseSet.order = sql`case ${exercise.id} ${sql.join(
exerciseOrderUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
sql` `
)} else ${exercise.order} end`;
}
if (exerciseSectionUpdates.length > 0) {
exerciseSet.sectionId = sql`case ${exercise.id} ${sql.join(
exerciseSectionUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
sql` `
)} else ${exercise.sectionId} end`;
}
if (exerciseUnlockUpdates.length > 0) {
exerciseSet.isUnlocked = sql`case ${exercise.id} ${sql.join(
exerciseUnlockUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
sql` `
)} else ${exercise.isUnlocked} end`;
}
const updatedExercises = await tx
.update(exercise)
.set(exerciseSet)
.where(inArray(exercise.id, exerciseIds))
.returning({ id: exercise.id });
if (updatedExercises.length !== exerciseIds.length) {
throw new AppError('Exercise not found', ErrorCodes.EXERCISE_NOT_FOUND, 404);
}
}
return {
updatedItems: updates.length,
updatedLessons: lessonUpdates.length,
updatedExercises: exerciseUpdates.length
};
}
async function applySectionOrderUpdates(tx: DbOrTxClient, sections: NonNullable<TCourseContentReorder['sections']>) {
if (sections.length === 0) {
return 0;
}
const sectionIds = sections.map((section) => section.id);
const updatedAt = new Date().toISOString();
const sectionSet: Record<string, unknown> = {
updatedAt,
order: sql`case ${courseSection.id} ${sql.join(
sections.map((section) => sql`when ${section.id} then ${section.order}`),
sql` `
)} else ${courseSection.order} end`
};
const updatedSections = await tx
.update(courseSection)
.set(sectionSet)
.where(inArray(courseSection.id, sectionIds))
.returning({ id: courseSection.id });
if (updatedSections.length !== sectionIds.length) {
throw new AppError('Course section not found', ErrorCodes.COURSE_SECTION_NOT_FOUND, 404);
}
return sections.length;
}
export async function updateCourseContent(courseId: string, items: TCourseContentUpdate['items']) {
try {
await assertCourseContentItems(courseId, items);
await db.transaction(async (tx) => {
const updates = items
.map((item) => ({
item,
payload: buildUpdatePayload(item)
}))
.filter(({ payload }) => Object.keys(payload).length > 0);
if (updates.length === 0) {
return;
}
const updatedAt = new Date().toISOString();
const lessonUpdates = updates.filter(({ item }) => item.type === ContentType.Lesson);
const exerciseUpdates = updates.filter(({ item }) => item.type === ContentType.Exercise);
if (lessonUpdates.length > 0) {
const lessonIds = lessonUpdates.map(({ item }) => item.id);
const lessonOrderUpdates = lessonUpdates
.filter(({ payload }) => payload.order !== undefined)
.map(({ item, payload }) => ({ id: item.id, value: payload.order }));
const lessonSectionUpdates = lessonUpdates
.filter(({ payload }) => payload.sectionId !== undefined)
.map(({ item, payload }) => ({ id: item.id, value: payload.sectionId }));
const lessonUnlockUpdates = lessonUpdates
.filter(({ payload }) => payload.isUnlocked !== undefined)
.map(({ item, payload }) => ({ id: item.id, value: payload.isUnlocked }));
const lessonSet: Record<string, unknown> = {
updatedAt
};
if (lessonOrderUpdates.length > 0) {
lessonSet.order = sql`case ${lesson.id} ${sql.join(
lessonOrderUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
sql` `
)} else ${lesson.order} end`;
}
if (lessonSectionUpdates.length > 0) {
lessonSet.sectionId = sql`case ${lesson.id} ${sql.join(
lessonSectionUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
sql` `
)} else ${lesson.sectionId} end`;
}
if (lessonUnlockUpdates.length > 0) {
lessonSet.isUnlocked = sql`case ${lesson.id} ${sql.join(
lessonUnlockUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
sql` `
)} else ${lesson.isUnlocked} end`;
}
const updatedLessons = await tx
.update(lesson)
.set(lessonSet)
.where(inArray(lesson.id, lessonIds))
.returning({ id: lesson.id });
if (updatedLessons.length !== lessonIds.length) {
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
}
}
if (exerciseUpdates.length > 0) {
const exerciseIds = exerciseUpdates.map(({ item }) => item.id);
const exerciseOrderUpdates = exerciseUpdates
.filter(({ payload }) => payload.order !== undefined)
.map(({ item, payload }) => ({ id: item.id, value: payload.order }));
const exerciseSectionUpdates = exerciseUpdates
.filter(({ payload }) => payload.sectionId !== undefined)
.map(({ item, payload }) => ({ id: item.id, value: payload.sectionId }));
const exerciseUnlockUpdates = exerciseUpdates
.filter(({ payload }) => payload.isUnlocked !== undefined)
.map(({ item, payload }) => ({ id: item.id, value: payload.isUnlocked }));
const exerciseSet: Record<string, unknown> = {
updatedAt
};
if (exerciseOrderUpdates.length > 0) {
exerciseSet.order = sql`case ${exercise.id} ${sql.join(
exerciseOrderUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
sql` `
)} else ${exercise.order} end`;
}
if (exerciseSectionUpdates.length > 0) {
exerciseSet.sectionId = sql`case ${exercise.id} ${sql.join(
exerciseSectionUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
sql` `
)} else ${exercise.sectionId} end`;
}
if (exerciseUnlockUpdates.length > 0) {
exerciseSet.isUnlocked = sql`case ${exercise.id} ${sql.join(
exerciseUnlockUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
sql` `
)} else ${exercise.isUnlocked} end`;
}
const updatedExercises = await tx
.update(exercise)
.set(exerciseSet)
.where(inArray(exercise.id, exerciseIds))
.returning({ id: exercise.id });
if (updatedExercises.length !== exerciseIds.length) {
throw new AppError('Exercise not found', ErrorCodes.EXERCISE_NOT_FOUND, 404);
}
}
await applyCourseContentUpdates(tx, items);
});
} catch (error) {
if (error instanceof AppError) {
@@ -194,6 +255,48 @@ export async function updateCourseContent(courseId: string, items: TCourseConten
}
}
export async function reorderCourseContent(
courseId: string,
payload: TCourseContentReorder
): Promise<CourseContentReorderResult> {
try {
if (payload.sections?.length) {
await assertCourseSections(courseId, payload.sections);
}
if (payload.items?.length) {
await assertCourseContentItems(courseId, payload.items);
}
return await db.transaction(async (tx) => {
const updatedSections = payload.sections?.length ? await applySectionOrderUpdates(tx, payload.sections) : 0;
const itemResult = payload.items?.length
? await applyCourseContentUpdates(tx, payload.items)
: {
updatedItems: 0,
updatedLessons: 0,
updatedExercises: 0
};
return {
courseId,
updatedSections,
...itemResult
};
});
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to reorder course content',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function deleteCourseContent(courseId: string, payload: TCourseContentDelete) {
try {
if (payload.sectionId) {
@@ -0,0 +1,224 @@
import * as schema from '@cio/db/schema';
import { AppError, ErrorCodes } from '@api/utils/errors';
import type { TCourse, TOrganization } from '@db/types';
import type { TCourseLandingPageMetadataUpdate, TCourseLandingPageUpdate } from '@cio/utils/validation/course';
import { db } from '@cio/db/drizzle';
import { env } from '@api/config/env';
import { eq } from 'drizzle-orm';
import { generateSlug } from '@cio/utils/functions';
import { getCourseById } from '@cio/db/queries/course';
import { getCourseOrganizationId } from '@cio/db/queries/tag';
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { getOrganizationById } from '@cio/db/queries/organization';
import { updateCourse } from '@api/services/course/course';
type CourseLandingPageUpdateResult = {
course: TCourse;
courseUrl: string;
bannerImageUrl: string | null;
};
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 [course] = await getCourseById(courseId);
if (course?.slug) {
return course.slug;
}
const baseSlug = generateSlug(title, { fallback: 'course' });
const uniqueSlug = await generateUniqueCourseSlug(baseSlug);
await updateCourse(courseId, { slug: uniqueSlug });
return uniqueSlug;
}
function buildCourseBaseUrl(organization: TOrganization) {
if (organization.customDomain && organization.isCustomDomainVerified) {
return `https://${organization.customDomain}`;
}
return getDashboardBaseUrl(organization.siteName ?? undefined);
}
async function resolveCourseUrl(organizationId: string, courseId: string, title: string) {
const organization = await getOrganizationById(organizationId);
if (!organization) {
throw new AppError('Organization not found', ErrorCodes.ORG_NOT_FOUND, 404);
}
const slug = await ensureCourseSlug(courseId, title);
return `${buildCourseBaseUrl(organization)}/course/${encodeURIComponent(slug)}`;
}
async function resolveUnsplashBannerImage(courseTitle: string, query?: string) {
if (!env.UNSPLASH_API_KEY) {
return null;
}
const searchQuery = (query?.trim() || courseTitle.trim() || 'education').slice(0, 120);
const response = await fetch(
`https://api.unsplash.com/search/photos?page=1&per_page=15&auto=format&fit=crop&w=2970&q=80&client_id=${env.UNSPLASH_API_KEY}&query=${encodeURIComponent(searchQuery)}`,
{ method: 'GET' }
);
if (!response.ok) {
return null;
}
const data = (await response.json()) as {
results?: Array<{
urls?: {
regular?: string;
};
}>;
};
const photos = data.results ?? [];
if (photos.length === 0) {
return null;
}
const photo = photos[Math.floor(Math.random() * photos.length)];
return photo?.urls?.regular ?? null;
}
async function resolveLandingPageImage(
courseTitle: string,
payload: Pick<TCourseLandingPageUpdate, 'generateImage' | 'imageQuery' | 'imageUrl'>
) {
if (payload.imageUrl) {
return payload.imageUrl;
}
if (!payload.generateImage && !payload.imageQuery) {
return null;
}
try {
return await resolveUnsplashBannerImage(courseTitle, payload.imageQuery);
} catch (error) {
console.error('resolveLandingPageImage error:', error);
return null;
}
}
function mergeLandingPageMetadata(
existing: TCourse['metadata'] | null | undefined,
update: TCourseLandingPageMetadataUpdate | undefined
): TCourse['metadata'] | undefined {
if (!update) {
return existing ?? undefined;
}
const current: NonNullable<TCourse['metadata']> = {
goals: '',
description: '',
requirements: '',
allowNewStudent: true,
...(existing ?? {})
};
return {
...current,
...update,
reward: update.reward
? {
show: update.reward.show ?? current.reward?.show ?? false,
description: update.reward.description ?? current.reward?.description ?? ''
}
: current.reward,
instructor: update.instructor
? {
name: update.instructor.name ?? current.instructor?.name ?? '',
role: update.instructor.role ?? current.instructor?.role ?? '',
coursesNo: update.instructor.coursesNo ?? current.instructor?.coursesNo ?? 0,
description: update.instructor.description ?? current.instructor?.description ?? '',
imgUrl: update.instructor.imgUrl ?? current.instructor?.imgUrl ?? ''
}
: current.instructor,
certificate: update.certificate
? {
templateUrl: update.certificate.templateUrl ?? current.certificate?.templateUrl ?? ''
}
: current.certificate,
sectionDisplay: update.sectionDisplay
? { ...(current.sectionDisplay ?? {}), ...update.sectionDisplay }
: current.sectionDisplay,
reviews: update.reviews ?? current.reviews,
lessonTabsOrder: update.lessonTabsOrder ?? current.lessonTabsOrder
};
}
function normalizeReviews(reviews: NonNullable<TCourse['metadata']>['reviews'] | undefined) {
return reviews?.map((review, index) => ({
id: review.id ?? Date.now() + index,
hide: review.hide ?? false,
name: review.name,
avatar_url: review.avatar_url,
rating: review.rating,
created_at: review.created_at ?? Date.now(),
description: review.description
}));
}
export async function updateCourseLandingPageService(
courseId: string,
payload: TCourseLandingPageUpdate
): Promise<CourseLandingPageUpdateResult> {
const [existingCourse] = await getCourseById(courseId);
if (!existingCourse) {
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
}
const nextTitle = payload.title ?? existingCourse.title;
const metadata = mergeLandingPageMetadata(existingCourse.metadata ?? undefined, payload.metadata);
const imageUrl = await resolveLandingPageImage(nextTitle, payload);
const updatedCourse = await updateCourse(courseId, {
title: payload.title,
description: payload.description,
overview: payload.overview,
cost: payload.cost,
currency: payload.currency,
metadata: metadata
? {
...metadata,
reviews: normalizeReviews(metadata.reviews)
}
: undefined,
...(imageUrl ? { logo: imageUrl, bannerImage: imageUrl } : {})
});
const organizationId = await getCourseOrganizationId(courseId);
if (!organizationId) {
throw new AppError('Course organization not found', ErrorCodes.COURSE_NOT_FOUND, 404);
}
const courseUrl = await resolveCourseUrl(organizationId, courseId, updatedCourse.title);
return {
course: updatedCourse,
courseUrl,
bannerImageUrl: updatedCourse.logo || imageUrl || null
};
}
@@ -15,13 +15,19 @@ import {
listOrganizationApiKeys,
updateOrganizationApiKey
} from '@cio/db/queries/organization';
import { assertOrganizationAutomationKeyCreationAllowed } from './automation-usage';
const DEFAULT_SCOPES: Record<TOrganizationApiKeyType, TOrganizationApiKeyScope[]> = {
mcp: [
'course_import:draft:create',
'course_import:draft:read',
'course_import:draft:update',
'course_import:draft:publish'
'course_import:draft:publish',
'course:read',
'course:write',
'course:tag:write',
'course:exercise:read',
'course:exercise:write'
],
api: ['public_api:*'],
zapier: ['public_api:*']
@@ -86,6 +92,8 @@ export async function createOrganizationApiKeyService(
createdByProfileId: string,
payload: TCreateOrganizationApiKey
): Promise<{ key: OrganizationApiKeySummary; secret: string }> {
await assertOrganizationAutomationKeyCreationAllowed(organizationId, payload.type);
const secret = generateOrganizationApiKeySecret(payload.type);
const key = await createOrganizationApiKey({
organizationId,
@@ -0,0 +1,188 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import {
countActiveOrganizationApiKeys,
countOrganizationAutomationUsageSince,
countOrganizationAutomationUsageSinceByKey,
createOrganizationAutomationUsage,
getActiveOrganizationPlan,
getOrganizationAutomationCreditsUsedSince,
listRecentOrganizationAutomationUsage
} from '@cio/db/queries/organization';
import type { TOrganizationApiKey, TOrganizationApiKeyType, TPlan } from '@db/types';
import {
AUTOMATION_TYPE,
getMcpAutomationCategory,
getMcpAutomationLimits,
MCP_TOOL_CREDIT_COST,
type TMcpToolName
} from '@cio/utils/plans';
export type OrganizationAutomationUsageSummary = {
type: TOrganizationApiKeyType;
planName: TPlan;
billingPeriodStart: string;
billingPeriodEnd: string;
monthlyCreditsIncluded: number;
monthlyCreditsUsed: number;
monthlyCreditsRemaining: number;
activeKeys: number;
maxActiveKeys: number;
rateLimits: ReturnType<typeof getMcpAutomationLimits>['rateLimits'];
recentActions: Array<{
action: string;
creditsConsumed: number;
createdAt: string;
}>;
};
function getBillingPeriodStart(date: Date = new Date()) {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1, 0, 0, 0, 0));
}
function getBillingPeriodEnd(date: Date = new Date()) {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1, 0, 0, 0, 0));
}
function getMinuteWindowStart(date: Date = new Date()) {
return new Date(date.getTime() - 60_000);
}
async function getOrganizationPlanName(orgId: string): Promise<TPlan> {
const activePlan = await getActiveOrganizationPlan(orgId);
return (activePlan?.planName as TPlan | null) ?? 'BASIC';
}
export async function assertOrganizationAutomationKeyCreationAllowed(
organizationId: string,
type: TOrganizationApiKeyType
): Promise<void> {
if (type !== AUTOMATION_TYPE.MCP) {
return;
}
const planName = await getOrganizationPlanName(organizationId);
const limits = getMcpAutomationLimits(planName);
const activeKeys = await countActiveOrganizationApiKeys(organizationId, type);
if (activeKeys >= limits.maxActiveKeys) {
throw new AppError(
`Your plan allows up to ${limits.maxActiveKeys} active MCP key${limits.maxActiveKeys === 1 ? '' : 's'}`,
ErrorCodes.AUTOMATION_KEY_LIMIT_EXCEEDED,
403
);
}
}
export async function getOrganizationAutomationUsageSummaryService(
organizationId: string,
type: TOrganizationApiKeyType
): Promise<OrganizationAutomationUsageSummary> {
const planName = await getOrganizationPlanName(organizationId);
const billingPeriodStart = getBillingPeriodStart();
const billingPeriodEnd = getBillingPeriodEnd();
if (type !== AUTOMATION_TYPE.MCP) {
return {
type,
planName,
billingPeriodStart: billingPeriodStart.toISOString(),
billingPeriodEnd: billingPeriodEnd.toISOString(),
monthlyCreditsIncluded: 0,
monthlyCreditsUsed: 0,
monthlyCreditsRemaining: 0,
activeKeys: await countActiveOrganizationApiKeys(organizationId, type),
maxActiveKeys: 0,
rateLimits: {
perKey: { readPerMinute: 0, writePerMinute: 0, publishPerMinute: 0 },
perOrg: { readPerMinute: 0, writePerMinute: 0, publishPerMinute: 0 }
},
recentActions: []
};
}
const limits = getMcpAutomationLimits(planName);
const [monthlyCreditsUsed, activeKeys, recentActions] = await Promise.all([
getOrganizationAutomationCreditsUsedSince(organizationId, type, billingPeriodStart.toISOString()),
countActiveOrganizationApiKeys(organizationId, type),
listRecentOrganizationAutomationUsage(organizationId, type, 10)
]);
return {
type,
planName,
billingPeriodStart: billingPeriodStart.toISOString(),
billingPeriodEnd: billingPeriodEnd.toISOString(),
monthlyCreditsIncluded: limits.monthlyCredits,
monthlyCreditsUsed,
monthlyCreditsRemaining: Math.max(limits.monthlyCredits - monthlyCreditsUsed, 0),
activeKeys,
maxActiveKeys: limits.maxActiveKeys,
rateLimits: limits.rateLimits,
recentActions: recentActions.map((action) => ({
action: action.action,
creditsConsumed: action.creditsConsumed,
createdAt: action.createdAt
}))
};
}
export async function assertMcpAutomationUsageAllowed(
automationKey: TOrganizationApiKey,
toolName: TMcpToolName
): Promise<void> {
const planName = await getOrganizationPlanName(automationKey.organizationId);
const limits = getMcpAutomationLimits(planName);
const category = getMcpAutomationCategory(toolName);
const now = new Date();
const billingPeriodStart = getBillingPeriodStart(now).toISOString();
const minuteWindowStart = getMinuteWindowStart(now).toISOString();
const [creditsUsed, keyRequestsInWindow, orgRequestsInWindow] = await Promise.all([
getOrganizationAutomationCreditsUsedSince(automationKey.organizationId, automationKey.type, billingPeriodStart),
countOrganizationAutomationUsageSinceByKey(automationKey.id, category, minuteWindowStart),
countOrganizationAutomationUsageSince(automationKey.organizationId, automationKey.type, category, minuteWindowStart)
]);
const creditsNeeded = MCP_TOOL_CREDIT_COST[toolName];
if (creditsUsed + creditsNeeded > limits.monthlyCredits) {
throw new AppError(
'Monthly automation credits exhausted for this organization',
ErrorCodes.AUTOMATION_CREDIT_LIMIT_EXCEEDED,
429
);
}
const keyLimit =
category === 'read'
? limits.rateLimits.perKey.readPerMinute
: category === 'write'
? limits.rateLimits.perKey.writePerMinute
: limits.rateLimits.perKey.publishPerMinute;
const orgLimit =
category === 'read'
? limits.rateLimits.perOrg.readPerMinute
: category === 'write'
? limits.rateLimits.perOrg.writePerMinute
: limits.rateLimits.perOrg.publishPerMinute;
if (keyRequestsInWindow >= keyLimit || orgRequestsInWindow >= orgLimit) {
throw new AppError('Automation rate limit exceeded', ErrorCodes.AUTOMATION_RATE_LIMIT_EXCEEDED, 429);
}
}
export async function recordMcpAutomationUsage(
automationKey: TOrganizationApiKey,
toolName: TMcpToolName,
metadata: Record<string, unknown> = {}
): Promise<void> {
await createOrganizationAutomationUsage({
organizationId: automationKey.organizationId,
organizationApiKeyId: automationKey.id,
type: automationKey.type,
action: toolName,
category: getMcpAutomationCategory(toolName),
creditsConsumed: MCP_TOOL_CREDIT_COST[toolName],
metadata
});
}
+71
View File
@@ -1,4 +1,6 @@
import type {
TAutomationCourseTagAssignment,
TAutomationDraftTagAssignment,
TCourseTagAssignment,
TTagCreate,
TTagGroupCreate,
@@ -8,6 +10,8 @@ import type {
import {
createTag,
createTagGroup,
findTagBySlug,
findTagGroupBySlug,
generateUniqueTagGroupSlug,
generateUniqueTagSlug,
getCourseOrganizationId,
@@ -21,6 +25,8 @@ import {
updateTagGroup
} from '@cio/db/queries/tag';
import { getOrgIdBySiteName, isUserOrgAdmin } from '@cio/db/queries/organization';
import { TAG_COLOR_OPTIONS, type TTagColor } from '@cio/utils/validation/tag';
import { slugifyTagValue } from '@cio/db/queries/tag';
import { AppError, ErrorCodes } from '@api/utils/errors';
@@ -34,6 +40,51 @@ function normalizeOptionalText(value: string | undefined): string | null | undef
return normalized.length > 0 ? normalized : null;
}
function normalizeAutomationTagNames(tagNames: string[]) {
return Array.from(new Map(tagNames.map((name) => [name.trim().toLowerCase(), name.trim()])).values()).filter(Boolean);
}
function pickTagColor(name: string): TTagColor {
const seed = Array.from(name).reduce((acc, char) => acc + char.charCodeAt(0), 0);
return TAG_COLOR_OPTIONS[seed % TAG_COLOR_OPTIONS.length] as TTagColor;
}
async function ensureAutomationTagGroup(orgId: string, groupName: string) {
const slug = slugifyTagValue(groupName);
const existing = await findTagGroupBySlug(orgId, slug);
if (existing) {
return existing;
}
return createOrganizationTagGroup(orgId, { name: groupName });
}
async function ensureAutomationTags(orgId: string, tagNames: string[], groupName: string) {
const normalizedTagNames = normalizeAutomationTagNames(tagNames);
const group = await ensureAutomationTagGroup(orgId, groupName);
const tags = [];
for (const tagName of normalizedTagNames) {
const slug = slugifyTagValue(tagName);
const existing = await findTagBySlug(orgId, slug);
if (existing) {
tags.push(existing);
continue;
}
const created = await createOrganizationTag(orgId, {
groupId: group.id,
name: tagName,
color: pickTagColor(tagName)
});
tags.push(created);
}
return tags;
}
export async function getOrganizationTagGroups(orgId: string) {
try {
return getTagGroupsWithTags(orgId);
@@ -301,3 +352,23 @@ export async function replaceCourseTags(
);
}
}
export async function assignCourseTagsByName(orgId: string, courseId: string, data: TAutomationDraftTagAssignment) {
const ensuredTags = await ensureAutomationTags(orgId, data.tagNames, data.groupName?.trim() || 'Automation');
const existingTags = data.mode === 'merge' ? await getCourseTags(orgId, courseId) : [];
const tagIds = Array.from(new Set([...existingTags.map((tag) => tag.id), ...ensuredTags.map((tag) => tag.id)]));
return replaceCourseTags(orgId, courseId, { tagIds });
}
export async function assignTagsToCoursesByName(orgId: string, data: TAutomationCourseTagAssignment) {
const normalizedCourseIds = Array.from(new Set(data.courseIds));
const results = [];
for (const courseId of normalizedCourseIds) {
const tags = await assignCourseTagsByName(orgId, courseId, data);
results.push({ courseId, tags });
}
return results;
}
+3
View File
@@ -67,6 +67,9 @@ export const ErrorCodes = {
ORGANIZATION_API_KEY_FETCH_FAILED: 'ORGANIZATION_API_KEY_FETCH_FAILED',
ORGANIZATION_API_KEY_REVOKE_FAILED: 'ORGANIZATION_API_KEY_REVOKE_FAILED',
ORGANIZATION_API_KEY_ROTATE_FAILED: 'ORGANIZATION_API_KEY_ROTATE_FAILED',
AUTOMATION_KEY_LIMIT_EXCEEDED: 'AUTOMATION_KEY_LIMIT_EXCEEDED',
AUTOMATION_CREDIT_LIMIT_EXCEEDED: 'AUTOMATION_CREDIT_LIMIT_EXCEEDED',
AUTOMATION_RATE_LIMIT_EXCEEDED: 'AUTOMATION_RATE_LIMIT_EXCEEDED',
TAG_GROUP_NOT_FOUND: 'TAG_GROUP_NOT_FOUND',
TAG_NOT_FOUND: 'TAG_NOT_FOUND',
TAG_FETCH_FAILED: 'TAG_FETCH_FAILED',
+8 -8
View File
@@ -18,14 +18,14 @@
"types": ["node"],
"paths": {
"@api/*": ["src/*"],
"@db/types": ["./node_modules/@cio/db/dist/types.d.ts"],
"@db/*": ["./node_modules/@cio/db/dist/*"],
"@cio/db/*": ["./node_modules/@cio/db/dist/*"],
"@cio/db/types": ["./node_modules/@cio/db/dist/types.d.ts"],
"@cio/utils": ["./node_modules/@cio/utils/dist/index.ts"],
"@cio/utils/*": ["./node_modules/@cio/utils/dist/*"],
"@cio/email/*": ["./node_modules/@cio/email/dist/*"],
"@cio/email": ["./node_modules/@cio/email/dist/index.d.ts"]
"@db/types": ["../../packages/db/src/types.ts"],
"@db/*": ["../../packages/db/src/*"],
"@cio/db/*": ["../../packages/db/src/*"],
"@cio/db/types": ["../../packages/db/src/types.ts"],
"@cio/utils": ["../../packages/utils/src/index.ts"],
"@cio/utils/*": ["../../packages/utils/src/*"],
"@cio/email/*": ["../../packages/email/src/*"],
"@cio/email": ["../../packages/email/src/index.ts"]
}
},
"include": ["src/**/*"],
+11 -4
View File
@@ -6,12 +6,19 @@ PUBLIC_IS_SELFHOSTED=false
# PUBLIC_APP_DESCRIPTION=A flexible LMS for your organization
# PUBLIC_OG_IMAGE_URL=https://app.yourdomain.com/og-image.png
# CSP: External domains for script-src, style-src, connect-src, frame-src, font-src.
# When PUBLIC_IS_SELFHOSTED=true: REQUIRED - no defaults, only these env vars are used.
# When PUBLIC_IS_SELFHOSTED=false: Optional - unset uses SaaS defaults.
# CSP: External domains allowed by Content-Security-Policy.
# These are runtime env vars — set them when running the container (no rebuild needed).
# When PUBLIC_IS_SELFHOSTED=true: starts with 'self' only; add your domains here.
# When PUBLIC_IS_SELFHOSTED=false: unset uses SaaS defaults.
# Comma-separated, e.g. ALLOWED_EXTERNAL_DOMAINS=assets.cdn.clsrio.com,cdnjs.cloudflare.com,cdn.plyr.io
# Per-directive: CSP_SCRIPT_SRC_DOMAINS, CSP_STYLE_SRC_DOMAINS, CSP_CONNECT_SRC_DOMAINS, CSP_FRAME_SRC_DOMAINS, CSP_FONT_SRC_DOMAINS
# ALLOWED_EXTERNAL_DOMAINS overrides all per-directive vars below.
# ALLOWED_EXTERNAL_DOMAINS=
# CSP_SCRIPT_SRC_DOMAINS=
# CSP_STYLE_SRC_DOMAINS=
# CSP_CONNECT_SRC_DOMAINS=
# CSP_FRAME_SRC_DOMAINS=
# CSP_FONT_SRC_DOMAINS=
# CSP_MEDIA_SRC_DOMAINS= (applies to both img-src and media-src)
# Unsplash setup - Optional if you want to use Unsplash images
UNSPLASH_API_KEY=
+99 -14
View File
@@ -46,22 +46,111 @@ html .dark {
border: 2px solid var(--scrollbarBG);
}
.prose {
color: var(--foreground);
}
.prose h1,
.prose h2,
.prose h3,
.prose h4,
.prose h5,
.prose h6 {
font-size: revert !important;
font-weight: revert !important;
margin-top: 0px !important;
margin-bottom: 16px !important;
line-height: 1.25 !important;
.prose h4 {
@apply scroll-m-20 tracking-tight text-balance;
color: var(--foreground);
}
.prose h1 {
@apply mt-0 text-4xl font-extrabold;
}
.prose h2 {
@apply mt-10 border-b pb-2 text-3xl font-semibold transition-colors;
}
.prose h2:first-child {
@apply mt-0;
}
.prose h3 {
@apply mt-8 text-2xl font-semibold;
}
.prose h4 {
@apply mt-8 text-xl font-semibold;
}
.prose p,
.prose li,
.prose blockquote {
@apply leading-7;
}
.prose p {
margin-top: 0px !important;
margin-bottom: 10px !important;
@apply my-0;
}
.prose p:not(:first-child) {
@apply mt-6;
}
.prose a {
@apply font-medium underline underline-offset-4;
color: var(--primary);
}
.prose blockquote {
@apply mt-6 border-s-2 ps-6 italic;
border-inline-start-color: var(--border);
}
.prose ul,
.prose ol {
@apply my-6 ms-6 p-0;
}
.prose ul {
@apply list-disc;
}
.prose ol {
@apply list-decimal;
}
.prose li {
@apply mt-2;
}
.prose table {
@apply my-6 w-full border-collapse;
}
.prose thead tr,
.prose tbody tr {
@apply m-0 border-t p-0;
border-top-color: var(--border);
}
.prose tbody tr:nth-child(even) {
background: var(--muted);
}
.prose th,
.prose td {
@apply border px-4 py-2 text-start;
border-color: var(--border);
}
.prose th {
@apply font-bold;
}
.prose th[align='center'],
.prose td[align='center'] {
@apply text-center;
}
.prose th[align='right'],
.prose td[align='right'] {
@apply text-end;
}
/* .dark .prose h1,
@@ -106,10 +195,6 @@ iframe.iframe {
}
}
.prose a {
color: var(--main-primary-color);
}
:not([data-slot='button']) > svg.lucide-icon:not(.custom) {
color: #666666;
}
+7 -4
View File
@@ -1,5 +1,6 @@
import { getSessionData } from '$lib/utils/services/auth/session';
import { getCioCookieString } from '$lib/utils/functions/cookies';
import { applyCspExtensions } from '$lib/utils/csp';
import { type Handle, redirect } from '@sveltejs/kit';
import { isPublicApiRoute, isPublicRoute } from '$lib/utils/functions/routes/isPublicRoute';
import { ROUTE } from '$lib/utils/constants/routes';
@@ -12,13 +13,15 @@ export const handle: Handle = async (args) => {
event.locals = sessionData;
}
// Handle API routes
let response: Response;
if (event.url.pathname.includes('/api')) {
return handleAPIRoutes(args);
response = await handleAPIRoutes(args);
} else {
response = await handlePagesRoutes(args);
}
// Handle other routes (ui pages, etc.)
return handlePagesRoutes(args);
return applyCspExtensions(response);
};
const handlePagesRoutes: Handle = async ({ event, resolve }) => {
@@ -0,0 +1,44 @@
/**
* Global SSO auth state for the current org. Shared by login and signup
* so we don't re-fetch when navigating between them.
*/
import { get, writable } from 'svelte/store';
import { INITIAL_SSO_AUTH_STATE, ssoStateFromOrgInfo, type OrgSsoInfoShape, type SsoAuthState } from './auth-sso';
export type AuthSsoStore = {
/** Org we loaded SSO info for; when nav changes org we re-fetch */
loadedForOrgId: string | null;
/** Org-level SSO state from getOrgSsoInfo */
ssoState: SsoAuthState;
orgSupportsSso: boolean;
};
const initial: AuthSsoStore = {
loadedForOrgId: null,
ssoState: INITIAL_SSO_AUTH_STATE,
orgSupportsSso: false
};
export const authSsoStore = writable<AuthSsoStore>(initial);
/**
* Ensures SSO info for the given org is loaded. If we already have data for
* this orgId, does nothing. Otherwise fetches and updates the store.
*/
export async function ensureSsoInfoLoaded(
orgId: string,
fetchOrgSsoInfo: (id: string) => Promise<OrgSsoInfoShape>
): Promise<void> {
const current = get(authSsoStore);
if (current.loadedForOrgId === orgId) return;
const orgInfo = await fetchOrgSsoInfo(orgId);
const ssoState = ssoStateFromOrgInfo(orgInfo);
authSsoStore.set({
loadedForOrgId: orgId,
ssoState,
orgSupportsSso: orgInfo?.hasSso ?? false
});
}
@@ -0,0 +1,101 @@
/**
* Shared SSO auth state and helpers for login/signup flows.
* Used by (auth)/login and (auth)/signup to avoid duplicating SSO logic.
*/
export type SsoAuthState = {
checking: boolean;
required: boolean;
available: boolean;
redirectUrl: string | null;
providerName: string | null;
providerId: string | null;
};
export const INITIAL_SSO_AUTH_STATE: SsoAuthState = {
checking: false,
required: false,
available: false,
redirectUrl: null,
providerName: null,
providerId: null
};
/** Shape returned by getOrgSsoInfo (avoids importing org types here). */
export type OrgSsoInfoShape = {
hasSso: boolean;
ssoRequired?: boolean;
redirectUrl?: string | null;
providerName?: string | null;
providerId?: string | null;
} | null;
/** Shape returned by discoverSso (avoids importing org types here). */
export type SsoDiscoveryResultShape = {
ssoRequired?: boolean;
ssoAvailable?: boolean;
redirectUrl?: string | null;
providerName?: string | null;
providerId?: string | null;
} | null;
export function ssoStateFromOrgInfo(orgInfo: OrgSsoInfoShape): SsoAuthState {
if (!orgInfo) return INITIAL_SSO_AUTH_STATE;
return {
checking: false,
required: orgInfo.ssoRequired ?? false,
available: orgInfo.hasSso ?? false,
redirectUrl: orgInfo.redirectUrl ?? null,
providerName: orgInfo.providerName ?? null,
providerId: orgInfo.providerId ?? null
};
}
export function ssoStateFromDiscovery(result: SsoDiscoveryResultShape): SsoAuthState {
if (!result) return INITIAL_SSO_AUTH_STATE;
return {
checking: false,
required: result.ssoRequired ?? false,
available: result.ssoAvailable ?? false,
redirectUrl: result.redirectUrl ?? null,
providerName: result.providerName ?? null,
providerId: result.providerId ?? null
};
}
export function buildSsoRedirectUrl(redirectParam: string | null): string {
const base = typeof window !== 'undefined' ? window.location.origin : '';
const path = redirectParam && redirectParam.startsWith('/') ? redirectParam : '';
return `${base}${path}`;
}
const SSO_EMAIL_DEBOUNCE_MS = 500;
export function createSsoEmailChecker(options: {
getEmail: () => string;
getOrgSupportsSso: () => boolean;
discoverSso: (email: string) => Promise<SsoDiscoveryResultShape>;
onChecking: () => void;
onResult: (state: SsoAuthState) => void;
onClear: () => void;
}) {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
return function handleEmailChange() {
options.onClear();
if (!options.getOrgSupportsSso()) return;
clearTimeout(timeoutId);
const email = options.getEmail();
if (!email || !email.includes('@')) {
options.onResult(INITIAL_SSO_AUTH_STATE);
return;
}
options.onChecking();
timeoutId = setTimeout(async () => {
const result = await options.discoverSso(email);
options.onResult(ssoStateFromDiscovery(result));
}, SSO_EMAIL_DEBOUNCE_MS);
};
}
@@ -1,7 +1,9 @@
import type {
AutomationKey,
AutomationKeyType,
AutomationUsage,
CreateAutomationKeyRequest,
GetAutomationUsageRequest,
ListAutomationKeysRequest,
RevokeAutomationKeyRequest,
RotateAutomationKeyRequest
@@ -15,6 +17,7 @@ class AutomationApi extends BaseApiWithErrors {
generatedSecret = $state<string | null>(null);
isFetched = $state(false);
keys = $state<AutomationKey[]>([]);
usage = $state<AutomationUsage | null>(null);
async listKeys(type?: AutomationKeyType) {
return this.execute<ListAutomationKeysRequest>({
@@ -30,6 +33,19 @@ class AutomationApi extends BaseApiWithErrors {
});
}
async getUsage(type: AutomationKeyType = 'mcp') {
return this.execute<GetAutomationUsageRequest>({
requestFn: () =>
classroomio.organization.automation.usage.$get({
query: { type }
}),
logContext: 'fetching automation usage',
onSuccess: (response) => {
this.usage = response.data;
}
});
}
async createKey(type: AutomationKeyType, label?: string) {
return this.execute<CreateAutomationKeyRequest>({
requestFn: () =>
@@ -43,6 +59,9 @@ class AutomationApi extends BaseApiWithErrors {
onSuccess: (response) => {
this.generatedSecret = response.data.secret;
this.keys = [response.data.key, ...this.keys];
if (this.usage) {
this.usage = { ...this.usage, activeKeys: this.usage.activeKeys + 1 };
}
snackbar.success('snackbar.automation.created');
}
});
@@ -54,6 +73,10 @@ class AutomationApi extends BaseApiWithErrors {
logContext: 'revoking automation key',
onSuccess: (response) => {
this.keys = this.keys.map((key) => (key.id === response.data.id ? response.data : key));
if (this.usage) {
const activeKeys = this.keys.filter((key) => key.type === 'mcp' && !key.revokedAt).length;
this.usage = { ...this.usage, activeKeys };
}
snackbar.success('snackbar.automation.revoked');
}
});
@@ -0,0 +1,14 @@
<script lang="ts">
import { t } from '$lib/utils/functions/translations';
import * as Alert from '@cio/ui/base/alert';
import * as Field from '@cio/ui/base/field';
</script>
<Field.Group class="w-full max-w-5xl! space-y-8 px-2">
<Alert.Callout
variant="information"
title={$t('automation.tabs.api')}
description={$t('automation.coming_soon')}
class="w-full"
/>
</Field.Group>
@@ -1,269 +0,0 @@
<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>
@@ -1 +1,3 @@
export { default as AutomationPage } from './automation.svelte';
export { default as ApiAutomationPage } from './api.svelte';
export { default as McpAutomationPage } from './mcp.svelte';
export { default as ZapierAutomationPage } from './zapier.svelte';
@@ -0,0 +1,239 @@
<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 * as Code from '@cio/ui/custom/code';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import * as Field from '@cio/ui/base/field';
import * as Table from '@cio/ui/base/table';
import * as Tabs from '@cio/ui/base/tabs';
import { IconButton } from '@cio/ui/custom/icon-button';
import CoinsIcon from '@lucide/svelte/icons/coins';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import GaugeIcon from '@lucide/svelte/icons/gauge';
import KeyIcon from '@lucide/svelte/icons/key';
import PlusIcon from '@lucide/svelte/icons/plus';
import SparklesIcon from '@lucide/svelte/icons/sparkles';
import { ActivityCard } from '$features/ui';
let activeSetupTab = $state('cursor');
let generatedSecret = $state<string | null>(null);
async function onGenerateKey() {
await automationApi.createKey('mcp');
generatedSecret = automationApi.generatedSecret;
automationApi.clearGeneratedSecret();
}
async function onRevokeKey(keyId: string) {
if (!confirm(t.get('automation.keys.revoke_confirm'))) return;
await automationApi.revokeKey(keyId);
}
async function onRotateKey(keyId: string) {
if (!confirm(t.get('automation.keys.rotate_confirm'))) return;
await automationApi.rotateKey(keyId);
generatedSecret = automationApi.generatedSecret;
automationApi.clearGeneratedSecret();
}
const mcpKeys = $derived(automationApi.keys.filter((key) => key.type === 'mcp'));
const usageCards = $derived.by(() => {
if (!automationApi.usage) return [];
return [
{
icon: CoinsIcon,
title: t.get('automation.mcp.usage.credits_title'),
percentage: automationApi.usage.monthlyCreditsRemaining,
description: `${t.get('automation.mcp.usage.used_label')} ${automationApi.usage.monthlyCreditsUsed} / ${automationApi.usage.monthlyCreditsIncluded}`,
hidePercentage: true
},
{
icon: KeyIcon,
title: t.get('automation.mcp.usage.keys_title'),
percentage: automationApi.usage.activeKeys,
description: `${t.get('automation.mcp.usage.used_label')} ${automationApi.usage.activeKeys} / ${automationApi.usage.maxActiveKeys}`,
hidePercentage: true
},
{
icon: GaugeIcon,
title: t.get('automation.mcp.usage.rate_limits_title'),
percentage: `${automationApi.usage.rateLimits.perKey.readPerMinute} / ${automationApi.usage.rateLimits.perKey.writePerMinute} / ${automationApi.usage.rateLimits.perKey.publishPerMinute}`,
description: `${t.get('automation.mcp.usage.rate_limits_description')}`,
hidePercentage: true
}
];
});
const isLastUsageCard = (index: number) => index === usageCards.length - 1;
</script>
<Field.Group class="mx-auto w-full space-y-2">
{#if !$isOrgAdmin}
<Alert.Callout
variant="information"
title={$t('automation.permissions.disabled_title')}
description={$t('automation.permissions.disabled_description')}
class="w-full"
/>
{/if}
{#if $isOrgAdmin && automationApi.usage}
<div class="grid gap-4 md:grid-cols-3">
{#each usageCards as card, i (i)}
<ActivityCard
activity={card}
classes={{
percentage: isLastUsageCard(i) ? 'text-lg' : undefined
}}
/>
{/each}
</div>
{/if}
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 font-medium">
<KeyIcon class="size-5" />
{$t('automation.mcp.keys.title')}
</div>
{#if $isOrgAdmin}
<IconButton
onclick={onGenerateKey}
disabled={automationApi.isLoading}
tooltip={$t('automation.mcp.keys.generate')}
>
<PlusIcon size={16} />
</IconButton>
{/if}
</div>
<p class="ui:text-muted-foreground text-sm">{$t('automation.mcp.keys.description')}</p>
{#if generatedSecret}
<div class="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={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}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>{$t('automation.keys.table_key')}</Table.Head>
<Table.Head>{$t('automation.keys.table_status')}</Table.Head>
<Table.Head>{$t('automation.keys.table_created')}</Table.Head>
<Table.Head>{$t('automation.keys.table_last_used')}</Table.Head>
<Table.Head class="w-10"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each mcpKeys as key (key.id)}
<Table.Row>
<Table.Cell>
<div>
<p class="font-medium">{key.label}</p>
<p class="ui:text-muted-foreground text-xs">
{getMaskedAutomationSecret(key.secretPrefix)}
</p>
</div>
</Table.Cell>
<Table.Cell>
<Badge variant={key.revokedAt ? 'secondary' : 'default'}>
{key.revokedAt ? $t('automation.keys.revoked') : $t('automation.keys.active')}
</Badge>
</Table.Cell>
<Table.Cell class="ui:text-muted-foreground text-sm">
{new Date(key.createdAt).toLocaleDateString()}
</Table.Cell>
<Table.Cell class="ui:text-muted-foreground text-sm">
{key.lastUsedAt ? new Date(key.lastUsedAt).toLocaleDateString() : $t('automation.keys.never_used')}
</Table.Cell>
<Table.Cell>
{#if !key.revokedAt}
<DropdownMenu.Root>
<DropdownMenu.Trigger class="inline-flex items-center justify-center">
<IconButton aria-label="actions">
<EllipsisVerticalIcon size={16} />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => onRotateKey(key.id)}>
{$t('automation.keys.rotate')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => onRevokeKey(key.id)} class="ui:text-destructive">
{$t('automation.keys.revoke')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{:else}
<Alert.Callout
variant="information"
title={$t('automation.keys.empty_title')}
description={$t('automation.keys.empty_description')}
class="w-full"
/>
{/if}
{/if}
</div>
<Field.Set class="gap-3!">
<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="cursor">{$t('automation.clients.cursor')}</Tabs.Trigger>
<Tabs.Trigger value="claude-code">{$t('automation.clients.claude_code')}</Tabs.Trigger>
<Tabs.Trigger value="codex">{$t('automation.clients.codex')}</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="claude-code" class="mt-4">
<Code.Overflow>
<Code.Root code={getClaudeCodeSnippet(generatedSecret)} lang="bash">
<Code.CopyButton />
</Code.Root>
</Code.Overflow>
</Tabs.Content>
<Tabs.Content value="codex" class="mt-4">
<Code.Overflow>
<Code.Root code={getCodexSnippet(generatedSecret)} lang="bash">
<Code.CopyButton />
</Code.Root>
</Code.Overflow>
</Tabs.Content>
<Tabs.Content value="cursor" class="mt-4">
<Code.Overflow>
<Code.Root code={getCursorSnippet(generatedSecret)} lang="json">
<Code.CopyButton />
</Code.Root>
</Code.Overflow>
</Tabs.Content>
</Tabs.Root>
</Field.Set>
</Field.Group>
@@ -0,0 +1,14 @@
<script lang="ts">
import { t } from '$lib/utils/functions/translations';
import * as Alert from '@cio/ui/base/alert';
import * as Field from '@cio/ui/base/field';
</script>
<Field.Group class="w-full max-w-5xl! space-y-8 px-2">
<Alert.Callout
variant="information"
title={$t('automation.tabs.zapier')}
description={$t('automation.coming_soon')}
class="w-full"
/>
</Field.Group>
@@ -1,15 +1,18 @@
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
export type ListAutomationKeysRequest = (typeof classroomio.organization)['automation']['keys']['$get'];
export type GetAutomationUsageRequest = (typeof classroomio.organization)['automation']['usage']['$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 GetAutomationUsageSuccess = Extract<InferResponseType<GetAutomationUsageRequest>, { 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'];
export type AutomationUsage = GetAutomationUsageSuccess['data'];
@@ -6,7 +6,7 @@
import { shortenName } from '$lib/utils/functions/string';
import { Vote } from '$features/ui';
import { CircleCheckIcon } from '$features/ui/icons';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
interface CommentAuthor {
id: number | string | null;
@@ -65,7 +65,7 @@
{/if}
</header>
<article class="prose prose-sm sm:prose p-2">
{@html sanitizeHtml(comment.body ?? '')}
<SafeHtmlContent content={comment.body ?? ''} />
</article>
</div>
</div>
@@ -22,7 +22,7 @@
import { CommunityDeleteModal, CommunityCommentItem } from '$features/community/components';
import type { CommunityQuestionSuccess } from '../utils/types';
import { currentCommunityQuestion } from '../utils/store';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
interface Props {
slug: string;
@@ -211,7 +211,7 @@
</div>
{:else}
<section class="prose prose-sm sm:prose p-2">
{@html sanitizeHtml(communityApi.question?.body ?? '')}
<SafeHtmlContent content={communityApi.question?.body ?? ''} />
</section>
{/if}
</div>
@@ -20,6 +20,7 @@
import getCurrencyFormatter from '$lib/utils/functions/getCurrencyFormatter';
import { calcCourseProgress, calcProgressRate } from '$features/course/utils/functions';
import CardDropdown from './card-dropdown.svelte';
import CoursePublishBadge from './course-publish-badge.svelte';
import type { OrgCourses, UserEnrolledCourses } from '$features/course/types';
import type { OrgPublicCourses } from '$features/org/utils/types';
@@ -196,13 +197,7 @@
</div>
{/if}
{:else}
<Badge variant={isPublished ? 'default' : 'outline'}>
{#if isPublished}
{$t('courses.course_card.published')}
{:else}
{$t('courses.course_card.unpublished')}
{/if}
</Badge>
<CoursePublishBadge {isPublished} />
{/if}
</div>
</div>
@@ -1,6 +1,7 @@
<script lang="ts">
import { COURSE_SORT_OPTIONS } from '../utils/constants';
import * as Popover from '@cio/ui/base/popover';
import * as ToggleGroup from '@cio/ui/base/toggle-group';
import { Button } from '@cio/ui/base/button';
import { Spinner } from '@cio/ui/base/spinner';
import { Checkbox } from '@cio/ui/base/checkbox';
@@ -27,6 +28,7 @@
interface Props {
selectedId?: string;
selectedOrder?: 'asc' | 'desc';
selectedTags?: string[];
tagGroups?: FilterTagGroup[];
sortOptions?: readonly SortOption[];
@@ -37,6 +39,7 @@
let {
selectedId = $bindable('0'),
selectedOrder = $bindable('desc' as 'asc' | 'desc'),
selectedTags = [],
tagGroups = [],
sortOptions = COURSE_SORT_OPTIONS,
@@ -47,8 +50,7 @@
let isOpen = $state(false);
const hasActiveFilters = $derived(selectedId !== '0' || selectedTags.length > 0);
const hasActiveTagFilters = $derived(selectedTags.length > 0);
const hasActiveFilters = $derived(selectedId !== '0' || selectedOrder !== 'desc' || selectedTags.length > 0);
function isTagSelected(tagSlug: string) {
return selectedTags.includes(tagSlug);
@@ -66,7 +68,7 @@
<FilterIcon size={16} />
{/if}
</IconButton>
{#if hasActiveTagFilters}
{#if hasActiveFilters}
<span
class="ui:bg-primary absolute -top-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border border-white"
aria-hidden="true"
@@ -103,6 +105,26 @@
</div>
</div>
<div class="space-y-2">
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
{$t('courses.tag_filters.order')}
</p>
<ToggleGroup.Root
type="single"
variant="outline"
size="sm"
value={selectedOrder}
onValueChange={(value) => {
if (value === 'asc' || value === 'desc') {
selectedOrder = value;
}
}}
>
<ToggleGroup.Item value="asc">{$t('courses.tag_filters.ascending')}</ToggleGroup.Item>
<ToggleGroup.Item value="desc">{$t('courses.tag_filters.descending')}</ToggleGroup.Item>
</ToggleGroup.Root>
</div>
<div class="space-y-3">
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">{$t('courses.tag_filters.tags')}</p>
{#if tagGroups.length === 0}
@@ -3,21 +3,35 @@
import * as Sidebar from '@cio/ui/base/sidebar';
import BellIcon from '@lucide/svelte/icons/bell';
import { Button } from '@cio/ui/base/button';
import { IconButton } from '@cio/ui/custom/icon-button';
import * as Popover from '@cio/ui/base/popover';
import Search from '$features/ui/search.svelte';
import { HoverableItem, PreviewIcon } from '@cio/ui/custom/moving-icons';
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
import * as Empty from '@cio/ui/base/empty';
import { currentOrg } from '$lib/utils/store/org';
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import { isStudentExperience } from '$lib/utils/store/app';
import { setupProgressApi } from '$features/setup/api/setup-progress.svelte';
import { courseApi } from '$features/course/api';
import { openCoursePreview } from '$features/course/utils/course-preview';
import { t } from '$lib/utils/functions/translations';
import CoursePublishBadge from './course-publish-badge.svelte';
const siteName = $derived($currentOrg.siteName);
const showCoursePublishBadge = $derived(!$isStudentExperience);
$effect(() => {
if (!siteName) return;
setupProgressApi.fetchSetupProgress(siteName);
});
function handlePreview() {
openCoursePreview({
courseId: courseApi.course?.id ?? '',
courseSlug: courseApi.course?.slug,
currentOrgDomain: $currentOrgDomain
});
}
</script>
<header
@@ -30,14 +44,18 @@
<Separator orientation="vertical" />
</div>
<p class="max-w-2xs truncate text-sm font-medium">
{courseApi.course?.title || ''}
</p>
<div class="flex min-w-0 items-center gap-2">
<p class="max-w-2xs truncate text-sm font-medium">
{courseApi.course?.title || ''}
</p>
{#if showCoursePublishBadge}
<CoursePublishBadge isPublished={courseApi.course?.isPublished} />
{/if}
</div>
<span class="grow"></span>
<Search />
<Popover.Root>
<Popover.Trigger>
<Button variant="outline" size="icon">
@@ -56,11 +74,24 @@
<Empty.Content>
<Button variant="outline" size="sm">
<RefreshCcwIcon />
Refresh
{$t('common.refresh')}
</Button>
</Empty.Content>
</Empty.Root>
</Popover.Content>
</Popover.Root>
<HoverableItem>
{#snippet children(isHovered)}
<IconButton
onclick={handlePreview}
disabled={!courseApi.course?.id}
tooltip={$t('course.header.preview')}
aria-label={$t('course.header.preview')}
>
<PreviewIcon {isHovered} size={16} />
</IconButton>
{/snippet}
</HoverableItem>
</div>
</header>
@@ -0,0 +1,18 @@
<script lang="ts">
import { Badge } from '@cio/ui/base/badge';
import { t } from '$lib/utils/functions/translations';
interface Props {
isPublished?: boolean;
}
let { isPublished = false }: Props = $props();
</script>
<Badge variant={isPublished ? 'default' : 'outline'}>
{#if isPublished}
{$t('courses.course_card.published')}
{:else}
{$t('courses.course_card.unpublished')}
{/if}
</Badge>
@@ -1,6 +1,6 @@
<script lang="ts">
import { questionnaire } from './store';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import { t } from '$lib/utils/functions/translations';
import { InputField } from '@cio/ui/custom/input-field';
@@ -49,9 +49,9 @@
</div>
<article class="preview prose prose-sm sm:prose mt-3 p-2">
{@html sanitizeHtml(
$questionnaire.description || $t('course.navItem.lessons.exercises.all_exercises.description.no')
)}
<SafeHtmlContent
content={$questionnaire.description || $t('course.navItem.lessons.exercises.all_exercises.description.no')}
/>
</article>
{:else}
<InputField
@@ -21,7 +21,7 @@
import { toApiPayload, type AnswerData } from '@cio/question-types';
import { exerciseApi, presignApi } from '$features/course/api';
import type { SubmissionListItem } from '$features/course/utils/types';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import { ContentType } from '@cio/utils/constants/content';
@@ -422,9 +422,10 @@
</div>
<article class="preview prose prose-sm sm:prose mt-3 p-2">
{@html sanitizeHtml(
$questionnaire.description || $t('course.navItem.lessons.exercises.all_exercises.view_mode.no_description')
)}
<SafeHtmlContent
content={$questionnaire.description ||
$t('course.navItem.lessons.exercises.all_exercises.view_mode.no_description')}
/>
</article>
<Button onclick={handleStart} type="button" class="float-right my-5">
@@ -8,7 +8,7 @@
import { lessonApi } from '$features/course/api';
import { diffLines } from 'diff';
import { courseApi } from '$features/course/api';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
@@ -83,7 +83,7 @@
);
}
function fetchLessonHistory(lessonId: string, locale: string, endRange: number) {
function fetchLessonHistory(lessonId: string, locale: TLocale, endRange: number) {
untrack(async () => {
try {
isMoreHistoryLoading = true;
@@ -100,9 +100,18 @@
const data = response.data;
// Filter out duplicates based on timestamp
const toLessonHistory = (item: (typeof data)[number]): LessonHistory => ({
old_content: item.oldContent ?? '',
new_content: item.newContent ?? '',
timestamp: item.timestamp ? new Date(item.timestamp) : new Date(),
locale: (item.locale as TLocale) ?? 'en',
lesson_id: item.lessonId ?? ''
});
const existingTimestamps = new Set(lessonHistory.map((item) => new Date(item.timestamp).getMinutes()));
const newEntries = data.filter((item) => !existingTimestamps.has(new Date(item.timestamp!).getMinutes()));
const newEntries = data
.filter((item) => !existingTimestamps.has(new Date(item.timestamp ?? 0).getMinutes()))
.map(toLessonHistory);
lessonHistory = removeDuplicate([...lessonHistory, ...newEntries]);
if (lessonHistory.length > 0) {
@@ -206,7 +215,7 @@
{#key lessonId}
<HTMLRender id="display" className="m-auto">
<div class="amen">
{@html sanitizeHtml(content)}
<SafeHtmlContent {content} />
</div>
</HTMLRender>
{/key}
@@ -222,7 +231,7 @@
</p>
<div>
{#each lessonHistory as version, index}
{#each lessonHistory as version, index (index)}
<button
onclick={() => updateContentVersion(version, index)}
class="flex w-full cursor-pointer items-start p-4 px-10 hover:bg-gray-200 dark:hover:bg-neutral-700 {index ==
@@ -1,6 +1,6 @@
<script lang="ts">
import { HTMLRender, TextEditor } from '$features/ui';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
import { lessonApi } from '$features/course/api';
import { t } from '$lib/utils/functions/translations';
@@ -43,10 +43,6 @@
}
const content = $derived(lessonApi.translations[lessonId]?.[lessonApi.currentLocale] || '');
$effect(() => {
console.log('content', content);
});
</script>
{#if mode === MODES.edit}
@@ -67,7 +63,7 @@
<!-- View Mode -->
{#if !isHtmlValueEmpty(content)}
<HTMLRender>
{@html sanitizeHtml(content)}
<SafeHtmlContent {content} />
</HTMLRender>
{:else if hasAtLeastOneTranslation}
<p class="text-md py-2 font-normal italic dark:text-white">
@@ -1,12 +1,12 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { Badge } from '@cio/ui/base/badge';
import * as Table from '@cio/ui/base/table';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { isMobileStore } from '@cio/ui/hooks/is-mobile.svelte';
import { t } from '$lib/utils/functions/translations';
import CoursePublishBadge from './course-publish-badge.svelte';
interface Props {
id?: string;
@@ -88,13 +88,7 @@
<Table.Cell>{totalLessons}</Table.Cell>
<Table.Cell>{totalStudents}</Table.Cell>
<Table.Cell>
<Badge variant={isPublished ? 'default' : 'outline'}>
{#if isPublished}
{$t('courses.course_card.published')}
{:else}
{$t('courses.course_card.unpublished')}
{/if}
</Badge>
<CoursePublishBadge {isPublished} />
</Table.Cell>
{/if}
<Table.Cell class="text-center">
@@ -4,7 +4,7 @@
import { calDateDiff } from '$lib/utils/functions/date';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import { t } from '$lib/utils/functions/translations';
import type { Feed } from '$features/course/utils/types';
@@ -59,7 +59,7 @@
{#if !isHtmlValueEmpty(feed.content || '')}
<HTMLRender className="w-[80%]">
<div>
{@html sanitizeHtml(feed.content || '')}
<SafeHtmlContent content={feed.content || ''} />
</div>
</HTMLRender>
{/if}
@@ -35,7 +35,7 @@
import { IconButton } from '@cio/ui/custom/icon-button';
import UpdateDescription from '$features/course/components/exercise/update-description.svelte';
import { ContentNavigationActions } from '$features/course/components/lesson';
import { RoleBasedSecurity } from '$features/ui';
import { RefreshPageData, RoleBasedSecurity } from '$features/ui';
import { getOrderedNavigableContent } from '$features/course/utils/content';
import { Empty } from '@cio/ui/custom/empty';
import VideoIcon from '@lucide/svelte/icons/video';
@@ -266,6 +266,8 @@
</div>
</div>
{/if}
<RefreshPageData />
</RoleBasedSecurity>
</div>
</Page.Action>
@@ -18,6 +18,7 @@
import { globalStore } from '$lib/utils/store/app';
import { currentOrg } from '$lib/utils/store/org';
import { snackbar } from '$features/ui/snackbar/store';
import { RefreshPageData } from '$features/ui';
import LessonVersionHistory from '$features/course/components/lesson/lesson-version-history.svelte';
import { courseApi, lessonApi } from '$features/course/api';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
@@ -260,6 +261,7 @@
if (!lessonApi.lesson || !lessonId) return;
if (!lessonApi.isDirty) return;
if ($isLoading) return;
console.log('autoSave');
autoSave();
});
@@ -268,6 +270,7 @@
let didHandleExitEdit = false;
$effect(() => {
if (!lessonId) return;
console.log('didHandleExitEdit');
const currentParam = $page.url.searchParams.get('mode');
const prev = prevModeParam;
@@ -345,8 +348,10 @@
</IconButton>
</div>
<LanguageSelector />
<RefreshPageData onRefresh={() => lessonApi.get(courseId, lessonId)} />
</RoleBasedSecurity>
<LanguageSelector />
</div>
</Page.Action>
</Page.Header>
@@ -0,0 +1,27 @@
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { snackbar } from '$features/ui/snackbar/store';
interface OpenCoursePreviewOptions {
courseId: string;
courseSlug?: string | null;
currentOrgDomain?: string;
}
export function openCoursePreview({ courseId, courseSlug, currentOrgDomain = '' }: OpenCoursePreviewOptions) {
if (!courseSlug) {
snackbar.info('course.header.preview_missing_slug');
if (courseId) {
goto(resolve(`/courses/${courseId}/settings`, {}));
}
return false;
}
const origin = currentOrgDomain || window.location.origin;
const link = new URL(resolve(`/course/${courseSlug}`, {}), origin).toString();
window.open(link, '_blank', 'noopener,noreferrer');
return true;
}
@@ -8,10 +8,12 @@
import * as Select from '@cio/ui/base/select';
import * as Table from '@cio/ui/base/table';
import { Empty } from '@cio/ui/custom/empty';
import { IconButton } from '@cio/ui/custom/icon-button';
import { InputField } from '@cio/ui/custom/input-field';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import TagIcon from '@lucide/svelte/icons/tag';
import { tagApi } from '$features/tag/api';
@@ -222,7 +224,7 @@
</Table.Header>
<Table.Body>
{#each tagApi.tagGroups as group (group.id)}
<Table.Row class="ui:bg-muted/20">
<Table.Row class="group ui:bg-muted/20">
<Table.Cell colspan={5}>
<div class="flex items-start justify-between gap-4 py-1">
<div class="space-y-1">
@@ -231,9 +233,22 @@
<p class="ui:text-muted-foreground max-w-[320px] truncate text-xs">{group.description}</p>
{/if}
</div>
<Button variant="ghost-default" size="sm" onclick={() => openEditTagGroupModal(group)}>
{$t('tags_admin.actions.edit_group')}
</Button>
<DropdownMenu.Root>
<DropdownMenu.Trigger class="flex shrink-0 items-center justify-center">
<IconButton
variant="outline"
class="pointer-events-none opacity-0 transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100"
aria-label={$t('tags_admin.actions.actions_menu')}
>
<EllipsisVerticalIcon size={16} />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => openEditTagGroupModal(group)}>
{$t('tags_admin.actions.edit_group')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</Table.Cell>
</Table.Row>
@@ -246,7 +261,7 @@
</Table.Row>
{:else}
{#each group.tags as tag (tag.id)}
<Table.Row>
<Table.Row class="group">
<Table.Cell>
<div class="flex items-center gap-2">
<span
@@ -269,9 +284,22 @@
</Table.Cell>
<Table.Cell>{group.name}</Table.Cell>
<Table.Cell class="text-right">
<Button variant="ghost-default" size="sm" onclick={() => openEditTagModal(tag)}>
{$t('tags_admin.actions.edit_tag')}
</Button>
<DropdownMenu.Root>
<DropdownMenu.Trigger class="inline-flex items-center justify-center">
<IconButton
variant="outline"
class="pointer-events-none opacity-0 transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100"
aria-label={$t('tags_admin.actions.actions_menu')}
>
<EllipsisVerticalIcon size={16} />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => openEditTagModal(tag)}>
{$t('tags_admin.actions.edit_tag')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
@@ -14,15 +14,23 @@
interface Props {
activity: Activity;
className?: string;
classes?: {
container?: string;
icon?: string;
title?: string;
percentage?: string;
description?: string;
};
}
let { activity, className = '' }: Props = $props();
let { activity, className = '', classes = {} }: Props = $props();
</script>
<div
class={cn(
'flex w-full flex-col items-center justify-evenly gap-2 rounded-xl border p-3 md:flex-row md:p-5',
className
'flex w-full flex-col items-center justify-evenly gap-2 rounded-xl border p-3 md:flex-row md:px-5 md:py-3',
className,
classes.container
)}
>
<div class="bg-primary-200 w-fit rounded-full p-4 text-black">
@@ -30,17 +38,19 @@
</div>
<div>
<p class="text-center text-sm font-medium text-gray-600 md:text-left dark:text-gray-200">
<p class={cn('text-center text-sm font-medium text-gray-600 md:text-left dark:text-gray-200', classes.title)}>
{activity.title}
</p>
<p class="text-center text-2xl md:text-left">
<p class={cn('text-center text-2xl md:text-left', classes.percentage)}>
{activity.percentage}
{#if !activity.hidePercentage}
%
{/if}
</p>
{#if activity.description}
<p class="text-center text-xs text-gray-500 md:text-left md:text-sm dark:text-gray-300">
<p
class={cn('text-center text-xs text-gray-500 md:text-left md:text-sm dark:text-gray-300', classes.description)}
>
{activity.description}
</p>
{/if}
@@ -1,4 +1,5 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { page } from '$app/state';
import * as Avatar from '@cio/ui/base/avatar';
import { t } from '$lib/utils/functions/translations';
@@ -19,7 +20,8 @@
hideGoogleAuth?: boolean;
redirectPathname?: string;
handleSubmit?: () => void;
children?: import('svelte').Snippet;
children?: Snippet;
getPasswordAuthAlternative?: Snippet;
}
let {
@@ -30,7 +32,8 @@
hideGoogleAuth = false,
redirectPathname = '',
handleSubmit = () => {},
children
children,
getPasswordAuthAlternative
}: Props = $props();
async function signInWithGoogle() {
@@ -100,12 +103,17 @@
<Separator />
<span class="ui:bg-card ui:text-muted-foreground absolute px-2 text-sm"> Or continue With </span>
</div>
<Button variant="outline" onclick={signInWithGoogle} disabled={isLoading} class="w-full">
<GoogleIconColored />
<span>
{isLogin ? $t('login.login_with_google') : $t('login.signup_with_google')}
</span>
</Button>
{#if getPasswordAuthAlternative}
{@render getPasswordAuthAlternative()}
{:else}
<Button variant="outline" onclick={signInWithGoogle} disabled={isLoading} class="w-full">
<GoogleIconColored />
<span>
{isLogin ? $t('login.login_with_google') : $t('login.signup_with_google')}
</span>
</Button>
{/if}
</div>
{/if}
</Card.Content>
@@ -1,5 +1,6 @@
<script lang="ts">
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
interface Props {
code?: string;
}
@@ -8,5 +9,5 @@
</script>
<div class="mb-3 rounded-md bg-gray-200 p-5 dark:bg-gray-500">
{@html sanitizeHtml(code)}
<SafeHtmlContent content={code} />
</div>
@@ -32,6 +32,7 @@
import { Button } from '@cio/ui/base/button';
import { courseApi } from '$features/course/api';
import { generateSlug } from '@cio/utils/functions';
import { openCoursePreview } from '$features/course/utils/course-preview';
import * as Sidebar from '@cio/ui/base/sidebar';
import { useSidebar } from '@cio/ui/base/sidebar';
@@ -164,9 +165,12 @@
syncCourseStore(course);
}
async function handlePreview() {
const link = `${$currentOrgDomain}/course/${course.slug}`;
window.open(link, '_blank');
function handlePreview() {
openCoursePreview({
courseId,
courseSlug: course.slug,
currentOrgDomain: $currentOrgDomain
});
}
function setter(value: any, setterKey: string) {
@@ -193,7 +197,7 @@
</Button>
<HoverableItem>
{#snippet children(isHovered)}
<IconButton onclick={handlePreview} disabled={loading || !course.slug}>
<IconButton onclick={handlePreview} disabled={loading} aria-label={$t('course.header.preview')}>
<PreviewIcon {isHovered} size={16} />
</IconButton>
{/snippet}
@@ -5,7 +5,7 @@
import { isCourseFree, calcCourseDiscount } from '$lib/utils/functions/course';
import { currentOrg } from '$lib/utils/store/org';
import { goto } from '$app/navigation';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import HTMLRender from '$features/ui/html-render.svelte';
import PaymentModal from './payment-modal.svelte';
import type { Course } from '$features/course/utils/types';
@@ -175,7 +175,9 @@
<!-- Gift Container -->
{#if courseData?.metadata?.reward?.show}
<div class="flex flex-col items-center border-t border-b border-gray-300 p-10">
<HTMLRender>{@html sanitizeHtml(get(courseData, 'metadata.reward.description', '') as string)}</HTMLRender>
<HTMLRender
><SafeHtmlContent content={get(courseData, 'metadata.reward.description', '') as string} /></HTMLRender
>
</div>
{/if}
</aside>
@@ -4,7 +4,7 @@
import { page } from '$app/state';
import { onMount, onDestroy } from 'svelte';
import { resolve } from '$app/paths';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import PlayIcon from '@lucide/svelte/icons/play';
import { getLectureNo } from '$features/course/utils/functions';
@@ -215,7 +215,7 @@
</h3>
<ul class="list font-light">
<HTMLRender>{@html sanitizeHtml(get(courseData, 'metadata.requirements', ''))}</HTMLRender>
<HTMLRender><SafeHtmlContent content={get(courseData, 'metadata.requirements', '')} /></HTMLRender>
</ul>
</NavSection>
{/if}
@@ -228,7 +228,7 @@
</h3>
<HTMLRender className="dark:text-white text-sm font-light">
{@html sanitizeHtml(get(courseData, 'metadata.description', ''))}
<SafeHtmlContent content={get(courseData, 'metadata.description', '')} />
</HTMLRender>
</NavSection>
{/if}
@@ -238,7 +238,7 @@
<NavSection id="goals">
<h3 class="mt-0 mb-3 text-2xl">{$t('course.navItem.landing_page.learn')}</h3>
<ul class="list font-light">
<HTMLRender>{@html sanitizeHtml(get(courseData, 'metadata.goals', ''))}</HTMLRender>
<HTMLRender><SafeHtmlContent content={get(courseData, 'metadata.goals', '')} /></HTMLRender>
</ul>
</NavSection>
{/if}
@@ -17,6 +17,7 @@ export * from './question';
export { default as UploadWidget } from './upload-widget/upload-widget.svelte';
export { default as PageLoadProgress } from './page-load-progress.svelte';
export { default as PoweredBy } from './upgrade-powered-by.svelte';
export { default as RefreshPageData } from './refresh-page-data.svelte';
export { default as RoleBasedSecurity } from './role-based-security.svelte';
export { default as Search } from './search.svelte';
export { default as SenjaEmbed } from './senja-embed.svelte';
@@ -108,7 +108,36 @@ const baseNavConfig: NavItemConfig[] = [
icon: BracesIcon,
requiresAdmin: true,
disableWhenNotAdmin: true,
matchPattern: '^/org/[^/]+/automation(/.*)?$'
useHashUrl: true,
matchPattern: '^/org/[^/]+/automation(/.*)?$',
items: [
{
titleKey: 'automation.tabs.mcp',
path: '/automation/mcp'
},
{
titleKey: 'automation.tabs.zapier',
path: '/automation/zapier'
},
{
titleKey: 'automation.tabs.api',
path: '/automation/api'
}
],
nestedRoutes: [
{
path: 'mcp',
titleKey: 'automation.tabs.mcp'
},
{
path: 'zapier',
titleKey: 'automation.tabs.zapier'
},
{
path: 'api',
titleKey: 'automation.tabs.api'
}
]
},
{
titleKey: 'org_navigation.settings',
@@ -0,0 +1,43 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
import { IconButton } from '@cio/ui/custom/icon-button';
import { t } from '$lib/utils/functions/translations';
interface Props {
disabled?: boolean;
onRefresh?: () => Promise<unknown> | unknown;
}
let { disabled = false, onRefresh }: Props = $props();
let isRefreshing = $state(false);
async function handleRefresh() {
if (disabled || isRefreshing) return;
isRefreshing = true;
try {
if (onRefresh) {
await onRefresh();
return;
}
await invalidateAll();
} finally {
isRefreshing = false;
}
}
</script>
<IconButton
onclick={handleRefresh}
disabled={disabled || isRefreshing}
tooltip={t.get('common.refresh')}
tooltipSide="bottom"
aria-label={t.get('common.refresh')}
>
<RefreshCcwIcon class={isRefreshing ? 'animate-spin' : ''} size={16} />
</IconButton>
@@ -0,0 +1,71 @@
// SaaS default CSP domains, baked in at build time.
// Self-hosted (adapter-node) starts with empty lists; runtime domains
// are added via env vars in hooks.server.ts so pre-built Docker images stay configurable.
const saasDefaults = {
scriptSrc: [
'https://assets.cdn.clsrio.com',
'https://cdnjs.cloudflare.com',
'https://*.posthog.com',
'https://*.senja.io',
'https://umami.hz.oncws.com',
'https://www.youtube.com',
'https://youtube.com',
'https://google.com'
],
styleSrc: [
'https://cdn.plyr.io',
'https://unpkg.com/[email protected]/dist/katex.min.css',
'https://assets.cdn.clsrio.com/eqneditor_1.css',
'https://fonts.googleapis.com'
],
connectSrc: [
'https://*.classroomio.com',
'https://classroomio.com',
'https://app.classroomio.com',
'https://api.classroomio.com',
'https://pgrest.classroomio.com',
'https://play.classroomio.com',
'wss://*.classroomio.com',
'https://assets.cdn.clsrio.com',
'https://cdn.plyr.io',
'https://*.posthog.com',
'https://umami.hz.oncws.com',
'https://*.r2.cloudflarestorage.com',
'https://*.senja.io',
'https://*.ytimg.com',
'https://noembed.com'
],
frameSrc: [
'https://www.youtube.com',
'https://youtube.com',
'https://www.youtube-nocookie.com',
'https://www.google.com',
'https://google.com'
],
fontSrc: ['https://fonts.gstatic.com', 'https://cdn.plyr.io'],
mediaSrc: ['https:']
};
/**
* @param {boolean} isSelfHosted
* @param {string | undefined} serverUrl - PUBLIC_SERVER_URL, added to connect-src for SaaS builds
*/
export function getCspDomains(isSelfHosted, serverUrl) {
if (isSelfHosted) {
return {
scriptSrc: [],
styleSrc: [],
connectSrc: [],
frameSrc: [],
fontSrc: [],
mediaSrc: [],
apiOrigin: null
};
}
return {
...saasDefaults,
apiOrigin: serverUrl ?? null
};
}
+75
View File
@@ -0,0 +1,75 @@
function parseCspDomains(value: string | undefined): string[] {
if (!value) return [];
return value
.split(',')
.map((d) => d.trim())
.filter(Boolean)
.map((d) => (d.startsWith('http://') || d.startsWith('https://') ? d : `https://${d}`));
}
function buildCspExtensions(): Record<string, string[]> {
const allDomains = parseCspDomains(process.env.ALLOWED_EXTERNAL_DOMAINS);
const or = (envVar: string) => (allDomains.length ? allDomains : parseCspDomains(process.env[envVar]));
const scriptSrc = or('CSP_SCRIPT_SRC_DOMAINS');
const styleSrc = or('CSP_STYLE_SRC_DOMAINS');
const connectSrcBase = or('CSP_CONNECT_SRC_DOMAINS');
const frameSrc = or('CSP_FRAME_SRC_DOMAINS');
const fontSrc = or('CSP_FONT_SRC_DOMAINS');
const mediaSrc = or('CSP_MEDIA_SRC_DOMAINS');
const serverUrl = process.env.PUBLIC_SERVER_URL;
const connectSrc = serverUrl ? [...connectSrcBase, serverUrl] : connectSrcBase;
const extensions: Record<string, string[]> = {};
if (scriptSrc.length) extensions['script-src'] = scriptSrc;
if (styleSrc.length) {
extensions['style-src'] = styleSrc;
extensions['style-src-elem'] = styleSrc;
}
if (connectSrc.length) extensions['connect-src'] = connectSrc;
if (frameSrc.length) extensions['frame-src'] = frameSrc;
if (fontSrc.length) extensions['font-src'] = fontSrc;
if (mediaSrc.length) {
extensions['img-src'] = mediaSrc;
extensions['media-src'] = mediaSrc;
}
return extensions;
}
let cachedExtensions: Record<string, string[]> | null = null;
function getExtensions(): Record<string, string[]> {
if (!cachedExtensions) cachedExtensions = buildCspExtensions();
return cachedExtensions;
}
function extendHeader(header: string, extensions: Record<string, string[]>): string {
return header
.split(';')
.map((d) => d.trim())
.filter(Boolean)
.map((directive) => {
const spaceIdx = directive.indexOf(' ');
const name = spaceIdx === -1 ? directive : directive.substring(0, spaceIdx);
const extra = extensions[name];
return extra?.length ? `${directive} ${extra.join(' ')}` : directive;
})
.join('; ');
}
export function applyCspExtensions(response: Response): Response {
const extensions = getExtensions();
if (Object.keys(extensions).length === 0) return response;
for (const name of ['content-security-policy', 'content-security-policy-report-only']) {
const header = response.headers.get(name);
if (header) {
response.headers.set(name, extendHeader(header, extensions));
}
}
return response;
}
@@ -3,6 +3,8 @@ import { browser } from '$app/environment';
export const isHtmlValueEmpty = (html: string) => {
if (!html || typeof html !== 'string') return true;
if (/<svg\b/i.test(html)) return false;
if (!document || !browser) return false;
return getTextFromHTML(html) === '';
@@ -1,5 +1,6 @@
{
"common": {
"refresh": "Opdater",
"unsaved_changes": {
"message": "Du har ikke-gemte ændringer. Er du sikker på, at du vil tage afsted?"
}
@@ -1508,6 +1509,10 @@
"placeholder": "Søg efter lektioner eller øvelser",
"empty": "Ingen resultater fundet"
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"sidebar": {
"back_to_course": "Tilbage til kurset",
"content_count": {
@@ -1582,6 +1587,12 @@
}
},
"course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "Dato oprettet",
"published": "Udgivet",
"lessons": "Lektioner"
@@ -1592,6 +1603,9 @@
"clear_all": "Ryd alle",
"popover_title": "Filtre",
"sort": "Sorter",
"order": "Rækkefølge",
"ascending": "Asc",
"descending": "Desc",
"tags": "Tags",
"empty_tags": "Ingen tags tilgængelige"
}
@@ -2212,7 +2226,6 @@
}
},
"login": {
"sso_enabled": "{provider} er aktiveret for dit domæne",
"sso_redirect_error": "SSO-logon er forkert konfigureret. Indstil PUBLIC_SERVER_URL til din API-server.",
"sso_required": "Log venligst ind med SSO. Adgangskodelogin er deaktiveret.",
"sign_in_with_sso": "Log ind med {provider}",
@@ -1,5 +1,6 @@
{
"common": {
"refresh": "Aktualisieren",
"unsaved_changes": {
"message": "Sie haben nicht gespeicherte Änderungen. Sind Sie sicher, dass Sie gehen möchten?"
}
@@ -1508,6 +1509,10 @@
"placeholder": "Suchen Sie nach Lektionen oder Übungen",
"empty": "Keine Ergebnisse gefunden"
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"sidebar": {
"back_to_course": "Zurück zum Kurs",
"content_count": {
@@ -1582,6 +1587,12 @@
}
},
"course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "Erstellungsdatum",
"published": "Veröffentlicht",
"lessons": "Lektionen"
@@ -1592,6 +1603,9 @@
"clear_all": "Alles löschen",
"popover_title": "Filter",
"sort": "Sortieren",
"order": "Reihenfolge",
"ascending": "Asc",
"descending": "Desc",
"tags": "Schlagworte",
"empty_tags": "Keine Tags verfügbar"
}
@@ -2212,7 +2226,6 @@
}
},
"login": {
"sso_enabled": "{provider} ist für Ihre Domain aktiviert",
"sso_redirect_error": "Die SSO-Anmeldung ist falsch konfiguriert. Bitte legen Sie PUBLIC_SERVER_URL auf Ihren API-Server fest.",
"sso_required": "Bitte melden Sie sich mit SSO an. Die Passwortanmeldung ist deaktiviert.",
"sign_in_with_sso": "Melden Sie sich mit {provider} an",
@@ -93,6 +93,9 @@
"placeholder": "Search"
}
},
"common": {
"refresh": "Refresh"
},
"login": {
"welcome": "Welcome back",
"email": "Your email",
@@ -240,7 +243,7 @@
},
"login": {
"or_continue_with": "Or continue with email and password",
"sso_enabled": "{provider} is enabled for your domain",
"sso_enabled": "{provider} is enabled for this organization",
"sso_redirect_error": "SSO sign-in is misconfigured. Please set PUBLIC_SERVER_URL to your API server.",
"sso_required": "Please sign in with SSO. Password login is disabled.",
"sign_in_with_sso": "Sign in with {provider}",
@@ -566,6 +569,7 @@
"actions": {
"create_tag": "Create Tag",
"create_group": "Create Tag Group",
"actions_menu": "Tag actions",
"edit_group": "Edit Group",
"edit_tag": "Edit Tag",
"save": "Save",
@@ -865,10 +869,19 @@
"clear_all": "Clear all",
"popover_title": "Filters",
"sort": "Sort",
"order": "Order",
"ascending": "Asc",
"descending": "Desc",
"tags": "Tags",
"empty_tags": "No tags available"
},
"course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "Date Created",
"published": "Published",
"lessons": "Lessons"
@@ -947,6 +960,10 @@
"placeholder": "Search lessons or exercises",
"empty": "No results found"
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"navItem": {
"news_feed": {
"heading": "News Feed",
@@ -2083,6 +2100,10 @@
"created_at_label": "Created:",
"last_used_label": "Last used:",
"never_used": "Never",
"table_key": "Key",
"table_status": "Status",
"table_created": "Created",
"table_last_used": "Last used",
"scopes_label": "Scopes:",
"revoke_confirm": "Revoke this automation key?",
"rotate_confirm": "Rotate this automation key?"
@@ -2097,9 +2118,7 @@
"keys_description": "Use one MCP key per integration so access can be rotated or revoked cleanly.",
"rate_limits_title": "Rate limits",
"rate_limits_description": "Read / write / publish requests allowed per minute.",
"used_label": "Used:",
"per_key_label": "Per key:",
"per_org_label": "Per org:"
"used_label": "Used:"
},
"keys": {
"title": "MCP keys",
@@ -1,5 +1,6 @@
{
"common": {
"refresh": "Actualizar",
"unsaved_changes": {
"message": "Tienes cambios sin guardar. ¿Estás seguro de que quieres irte?"
}
@@ -1508,6 +1509,10 @@
"placeholder": "Buscar lecciones o ejercicios",
"empty": "No se encontraron resultados"
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"sidebar": {
"back_to_course": "volver al curso",
"content_count": {
@@ -1582,6 +1587,12 @@
}
},
"course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "Fecha de creación",
"published": "Publicado",
"lessons": "Lecciones"
@@ -1592,6 +1603,9 @@
"clear_all": "Borrar todo",
"popover_title": "Filtros",
"sort": "ordenar",
"order": "Orden",
"ascending": "Asc",
"descending": "Desc",
"tags": "Etiquetas",
"empty_tags": "No hay etiquetas disponibles"
}
@@ -2212,7 +2226,6 @@
}
},
"login": {
"sso_enabled": "{provider} está habilitado para tu dominio",
"sso_redirect_error": "El inicio de sesión SSO está mal configurado. Configure PUBLIC_SERVER_URL en su servidor API.",
"sso_required": "Inicie sesión con SSO. El inicio de sesión con contraseña está deshabilitado.",
"sign_in_with_sso": "Iniciar sesión con {proveedor}",
@@ -1,5 +1,6 @@
{
"common": {
"refresh": "Actualiser",
"unsaved_changes": {
"message": "Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir partir ?"
}
@@ -1508,6 +1509,10 @@
"placeholder": "Rechercher des leçons ou des exercices",
"empty": "Aucun résultat trouvé"
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"sidebar": {
"back_to_course": "Retour au cours",
"content_count": {
@@ -1582,6 +1587,12 @@
}
},
"course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "Date de création",
"published": "Publié",
"lessons": "Leçons"
@@ -1592,6 +1603,9 @@
"clear_all": "Tout effacer",
"popover_title": "Filtres",
"sort": "Trier",
"order": "Ordre",
"ascending": "Asc",
"descending": "Desc",
"tags": "Balises",
"empty_tags": "Aucune balise disponible"
}
@@ -2212,7 +2226,6 @@
}
},
"login": {
"sso_enabled": "{provider} est activé pour votre domaine",
"sso_redirect_error": "La connexion SSO est mal configurée. Veuillez définir PUBLIC_SERVER_URL sur votre serveur API.",
"sso_required": "Veuillez vous connecter avec SSO. La connexion par mot de passe est désactivée.",
"sign_in_with_sso": "Connectez-vous avec {provider}",
@@ -1,5 +1,6 @@
{
"common": {
"refresh": "रीफ़्रेश करें",
"unsaved_changes": {
"message": "आपके पास सहेजे नहीं गए परिवर्तन हैं. क्या आपकी वाकई छोड़ने की इच्छा है?"
}
@@ -1508,6 +1509,10 @@
"placeholder": "पाठ या अभ्यास खोजें",
"empty": "कोई परिणाम नहीं मिला"
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"sidebar": {
"back_to_course": "पाठ्यक्रम पर वापस जाएँ",
"content_count": {
@@ -1582,6 +1587,12 @@
}
},
"course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "दिनांक निर्मित",
"published": "प्रकाशित",
"lessons": "सबक"
@@ -1592,6 +1603,9 @@
"clear_all": "सब साफ़ करें",
"popover_title": "फिल्टर",
"sort": "क्रमबद्ध करें",
"order": "क्रम",
"ascending": "Asc",
"descending": "Desc",
"tags": "टैग",
"empty_tags": "कोई टैग उपलब्ध नहीं है"
}
@@ -2212,7 +2226,6 @@
}
},
"login": {
"sso_enabled": "आपके डोमेन के लिए {प्रदाता} सक्षम है",
"sso_redirect_error": "SSO साइन-इन ग़लत कॉन्फ़िगर किया गया है. कृपया अपने एपीआई सर्वर पर PUBLIC_SERVER_URL सेट करें।",
"sso_required": "कृपया एसएसओ के साथ साइन इन करें। पासवर्ड लॉगिन अक्षम है.",
"sign_in_with_sso": "{प्रदाता} के साथ साइन इन करें",
@@ -1,5 +1,6 @@
{
"common": {
"refresh": "Odśwież",
"unsaved_changes": {
"message": "Masz niezapisane zmiany. Czy na pewno chcesz wyjść?"
}
@@ -1508,6 +1509,10 @@
"placeholder": "Wyszukaj lekcje lub ćwiczenia",
"empty": "Nie znaleziono żadnych wyników"
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"sidebar": {
"back_to_course": "Powrót na kurs",
"content_count": {
@@ -1582,6 +1587,12 @@
}
},
"course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "Data utworzenia",
"published": "Opublikowano",
"lessons": "Lekcje"
@@ -1592,6 +1603,9 @@
"clear_all": "Wyczyść wszystko",
"popover_title": "Filtry",
"sort": "Sortuj",
"order": "Kolejność",
"ascending": "Asc",
"descending": "Desc",
"tags": "Tagi",
"empty_tags": "Brak dostępnych tagów"
}
@@ -2212,7 +2226,6 @@
}
},
"login": {
"sso_enabled": "Usługa {provider} jest włączona w Twojej domenie",
"sso_redirect_error": "Logowanie jednokrotne jest błędnie skonfigurowane. Ustaw PUBLIC_SERVER_URL na swój serwer API.",
"sso_required": "Proszę zalogować się za pomocą SSO. Logowanie hasłem jest wyłączone.",
"sign_in_with_sso": "Zaloguj się przez {provider}",
@@ -1,5 +1,6 @@
{
"common": {
"refresh": "Atualizar",
"unsaved_changes": {
"message": "Você tem alterações não salvas. Tem certeza de que deseja sair?"
}
@@ -1508,6 +1509,10 @@
"placeholder": "Pesquise aulas ou exercícios",
"empty": "Nenhum resultado encontrado"
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"sidebar": {
"back_to_course": "De volta ao curso",
"content_count": {
@@ -1582,6 +1587,12 @@
}
},
"course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "Data de criação",
"published": "Publicado",
"lessons": "Lições"
@@ -1592,6 +1603,9 @@
"clear_all": "Limpar tudo",
"popover_title": "Filtros",
"sort": "Classificar",
"order": "Ordem",
"ascending": "Asc",
"descending": "Desc",
"tags": "Etiquetas",
"empty_tags": "Nenhuma tag disponível"
}
@@ -2212,7 +2226,6 @@
}
},
"login": {
"sso_enabled": "{provider} está ativado para seu domínio",
"sso_redirect_error": "O login do SSO está configurado incorretamente. Defina PUBLIC_SERVER_URL para seu servidor API.",
"sso_required": "Faça login com SSO. O login por senha está desabilitado.",
"sign_in_with_sso": "Faça login com {provedor}",
@@ -1,5 +1,6 @@
{
"common": {
"refresh": "Обновить",
"unsaved_changes": {
"message": "У вас есть несохраненные изменения. Вы уверены, что хотите уйти?"
}
@@ -1508,6 +1509,10 @@
"placeholder": "Поиск уроков или упражнений",
"empty": "Результаты не найдены"
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"sidebar": {
"back_to_course": "Вернуться к курсу",
"content_count": {
@@ -1582,6 +1587,12 @@
}
},
"course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "Дата создания",
"published": "Опубликовано",
"lessons": "Уроки"
@@ -1592,6 +1603,9 @@
"clear_all": "Очистить все",
"popover_title": "Фильтры",
"sort": "Сортировать",
"order": "Порядок",
"ascending": "Asc",
"descending": "Desc",
"tags": "Теги",
"empty_tags": "Нет доступных тегов"
}
@@ -2212,7 +2226,6 @@
}
},
"login": {
"sso_enabled": "{provider} включен для вашего домена",
"sso_redirect_error": "Вход в систему единого входа настроен неправильно. Установите PUBLIC_SERVER_URL для вашего сервера API.",
"sso_required": "Пожалуйста, войдите с помощью SSO. Вход по паролю отключен.",
"sign_in_with_sso": "Войдите через {provider}",
@@ -1,5 +1,6 @@
{
"common": {
"refresh": "Làm mới",
"unsaved_changes": {
"message": "Bạn có những thay đổi chưa được lưu. Bạn có chắc chắn muốn rời đi không?"
}
@@ -1508,6 +1509,10 @@
"placeholder": "Tìm kiếm bài học hoặc bài tập",
"empty": "Không tìm thấy kết quả nào"
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"sidebar": {
"back_to_course": "Quay lại khóa học",
"content_count": {
@@ -1582,6 +1587,12 @@
}
},
"course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "Ngày tạo",
"published": "Đã xuất bản",
"lessons": "Bài học"
@@ -1592,6 +1603,9 @@
"clear_all": "Xóa tất cả",
"popover_title": "Bộ lọc",
"sort": "Sắp xếp",
"order": "Thứ tự",
"ascending": "Asc",
"descending": "Desc",
"tags": "Thẻ",
"empty_tags": "Không có thẻ nào"
}
@@ -2212,7 +2226,6 @@
}
},
"login": {
"sso_enabled": "{provider} đã được bật cho miền của bạn",
"sso_redirect_error": "Đăng nhập SSO bị định cấu hình sai. Vui lòng đặt PUBLIC_SERVER_URL cho máy chủ API của bạn.",
"sso_required": "Vui lòng đăng nhập bằng SSO. Đăng nhập bằng mật khẩu bị vô hiệu hóa.",
"sign_in_with_sso": "Đăng nhập bằng {nhà cung cấp}",
@@ -15,6 +15,8 @@
import { Button } from '@cio/ui/base/button';
import { ssoApi } from '$features/org/api/sso.svelte';
import ShieldIcon from '@lucide/svelte/icons/shield';
import { buildSsoRedirectUrl, createSsoEmailChecker, type SsoAuthState } from '$features/auth/utils/auth-sso';
import { authSsoStore, ensureSsoInfoLoaded } from '$features/auth/utils/auth-sso-store';
const emailFromUrl = page.url.searchParams.get('email') ?? '';
const isEmailPrefilled = !!emailFromUrl;
@@ -24,102 +26,45 @@
let submitError: string | undefined = $state();
let loading = $state(false);
let errors = $state(Object.assign({}, LOGIN_FIELDS));
let isSSOInfoLoaded = $state(false);
let orgSupportsSso = $state(false);
// SSO state
let ssoState = $state<{
checking: boolean;
required: boolean;
available: boolean;
redirectUrl: string | null;
providerName: string | null;
providerId: string | null;
}>({
checking: false,
required: false,
available: false,
redirectUrl: null,
providerName: null,
providerId: null
});
/** Per-email discovery result; display uses this over org-level when set */
let discoveryState = $state<SsoAuthState | null>(null);
const redirectUrl = $derived(page.url.searchParams.get('redirect'));
const hideGoogleAuth = $derived(!!($globalStore.isOrgSite && $currentOrg.disableGoogleAuth));
async function initSSOInfo() {
if (isSSOInfoLoaded || !$currentOrg.id) return;
isSSOInfoLoaded = true;
const ssoInfo = await ssoApi.getOrgSsoInfo($currentOrg.id);
console.log('ssoInfo', ssoInfo);
if (ssoInfo) {
orgSupportsSso = ssoInfo.hasSso;
ssoState = {
checking: false,
required: ssoInfo.ssoRequired,
available: ssoInfo.hasSso,
redirectUrl: ssoInfo.redirectUrl,
providerName: ssoInfo.providerName,
providerId: ssoInfo.providerId
};
}
}
const ssoState = $derived(discoveryState ?? $authSsoStore.ssoState);
const orgSupportsSso = $derived($authSsoStore.orgSupportsSso);
$effect(() => {
initSSOInfo();
const orgId = $currentOrg.id;
if (orgId) ensureSsoInfoLoaded(orgId, (id) => ssoApi.getOrgSsoInfo(id));
});
let emailCheckTimeout: ReturnType<typeof setTimeout>;
function handleEmailChange() {
errors.email = '';
if (!orgSupportsSso) return;
clearTimeout(emailCheckTimeout);
emailCheckTimeout = setTimeout(async () => {
if (!fields.email || !fields.email.includes('@')) {
ssoState = {
checking: false,
required: false,
available: false,
redirectUrl: null,
providerName: null,
providerId: null
};
return;
}
ssoState.checking = true;
const result = await ssoApi.discoverSso(fields.email);
ssoState.checking = false;
if (result) {
ssoState = {
checking: false,
required: result.ssoRequired,
available: result.ssoAvailable,
redirectUrl: result.redirectUrl,
providerName: result.providerName,
providerId: result.providerId
};
}
}, 500);
}
const handleEmailChange = createSsoEmailChecker({
getEmail: () => fields.email,
getOrgSupportsSso: () => orgSupportsSso,
discoverSso: (email) => ssoApi.discoverSso(email),
onChecking: () => {
discoveryState = { ...$authSsoStore.ssoState, checking: true };
},
onResult: (state) => {
discoveryState = state;
},
onClear: () => {
errors.email = '';
discoveryState = null;
}
});
async function handleSsoLogin() {
const url = `${window.location.origin}${ssoState.redirectUrl || ''}`;
const url = buildSsoRedirectUrl(ssoState.redirectUrl);
const res = await authClient.signIn.sso({
await authClient.signIn.sso({
email: fields.email,
providerId: ssoState.providerId || '',
callbackURL: url
});
console.log('res', res);
}
async function handleSubmit() {
@@ -182,7 +127,32 @@
<title>Welcome back to {$currentOrg.name || 'ClassroomIO'}</title>
</svelte:head>
<AuthUI isLogin={true} {handleSubmit} isLoading={loading} {hideGoogleAuth}>
{#snippet getPasswordAuthAlternative()}
<!-- SSO Section (when SSO is available) -->
<div class="space-y-3">
{#if ssoState.required}
<div class="flex items-center gap-3">
<ShieldIcon class="size-5 text-blue-600" />
<div class="flex-1">
<p class="mt-1 text-xs text-blue-600">
{$t('settings.auth.login.sso_required')}
</p>
</div>
</div>
{/if}
<Button type="button" variant="outline" class="w-full" onclick={handleSsoLogin}>
{$t('settings.auth.login.sign_in_with_sso', { provider: ssoState.providerName || 'SSO' })}
</Button>
</div>
{/snippet}
<AuthUI
isLogin={true}
{handleSubmit}
isLoading={loading}
{hideGoogleAuth}
getPasswordAuthAlternative={ssoState.available ? getPasswordAuthAlternative : undefined}
>
<div class="ui:flex ui:flex-col ui:gap-6">
<Field.Field>
<Field.Label for="email">{$t('login.email')}</Field.Label>
@@ -205,44 +175,6 @@
</Field.Content>
</Field.Field>
<!-- SSO Section (when SSO is available) -->
{#if ssoState.available}
<div
class="ui:p-4 ui:bg-blue-50 ui:dark:bg-blue-900/20 ui:rounded-lg ui:border ui:border-blue-200 ui:dark:border-blue-800"
>
<div class="ui:flex ui:items-center ui:gap-3">
<ShieldIcon class="ui:size-5 ui:text-blue-600" />
<div class="ui:flex-1">
<p class="ui:font-medium ui:text-sm">
{$t('settings.auth.login.sso_enabled', { provider: ssoState.providerName || 'SSO' })}
</p>
{#if ssoState.required}
<p class="ui:text-xs ui:text-blue-600 ui:mt-1">
{$t('settings.auth.login.sso_required')}
</p>
{/if}
</div>
</div>
<Button type="button" variant="default" class="ui:w-full ui:mt-3" onclick={handleSsoLogin}>
{$t('settings.auth.login.sign_in_with_sso', { provider: ssoState.providerName || 'SSO' })}
</Button>
</div>
{/if}
<!-- Divider when both SSO and password login are available -->
{#if ssoState.available && !ssoState.required && !($globalStore.isOrgSite && $currentOrg.disableEmailPassword)}
<div class="ui:relative ui:my-4">
<div class="ui:absolute ui:inset-0 ui:flex ui:items-center" aria-hidden="true">
<div class="ui:w-full ui:border-t ui:border-border"></div>
</div>
<div class="ui:relative ui:flex ui:justify-center ui:text-xs">
<span class="ui:bg-card ui:px-2 ui:text-muted-foreground">
{$t('settings.auth.login.or_continue_with')}
</span>
</div>
</div>
{/if}
<!-- Password Section (when not force SSO and email/password not disabled) -->
{#if !ssoState.required && !($globalStore.isOrgSite && $currentOrg.disableEmailPassword)}
<Field.Field>
@@ -16,6 +16,10 @@
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { ssoApi } from '$features/org/api/sso.svelte';
import ShieldIcon from '@lucide/svelte/icons/shield';
import { buildSsoRedirectUrl, createSsoEmailChecker, type SsoAuthState } from '$features/auth/utils/auth-sso';
import { authSsoStore, ensureSsoInfoLoaded } from '$features/auth/utils/auth-sso-store';
let { data } = $props();
const emailFromUrl = page.url.searchParams.get('email') ?? '';
@@ -29,6 +33,9 @@
} = $state({});
let submitError: string = $state('');
/** Per-email discovery result; display uses this over org-level when set */
let discoveryState = $state<SsoAuthState | null>(null);
const disableSubmit = $derived(getDisableSubmit(fields));
const redirectUrl = $derived(page.url.searchParams.get('redirect'));
const inviteToken = $derived(page.url.searchParams.get('invite_token'));
@@ -36,6 +43,9 @@
// Use org from store or layout data (layout data ensures org is available on self-hosted before store is set)
const org = $derived($currentOrg?.id ? $currentOrg : (data.org ?? $currentOrg));
const ssoState = $derived(discoveryState ?? $authSsoStore.ssoState);
const orgSupportsSso = $derived($authSsoStore.orgSupportsSso);
// Invite context: allow signup when invite_token present or redirect contains invite info
const hasInviteContext = $derived(
!!inviteToken || (!!redirectUrl && (redirectUrl.includes('/invite/t/') || redirectUrl.includes('invite_token')))
@@ -54,12 +64,48 @@
}
});
$effect(() => {
const orgId = org?.id;
if (orgId) ensureSsoInfoLoaded(orgId, (id) => ssoApi.getOrgSsoInfo(id));
});
const handleEmailChange = createSsoEmailChecker({
getEmail: () => fields.email,
getOrgSupportsSso: () => orgSupportsSso,
discoverSso: (email) => ssoApi.discoverSso(email),
onChecking: () => {
discoveryState = { ...$authSsoStore.ssoState, checking: true };
},
onResult: (state) => {
discoveryState = state;
},
onClear: () => {
errors.email = '';
discoveryState = null;
}
});
async function handleSsoLogin() {
const url = buildSsoRedirectUrl(ssoState.redirectUrl);
await authClient.signIn.sso({
email: fields.email,
providerId: ssoState.providerId || '',
callbackURL: url
});
}
async function handleSubmit() {
if (signupRestricted) {
submitError = t.get('settings.auth.login.signup_disabled_error');
return;
}
if (ssoState.required && ssoState.available) {
handleSsoLogin();
return;
}
const validationRes = authValidation(fields);
console.log('validationRes', validationRes);
@@ -151,7 +197,31 @@
</Button>
</div>
{:else}
<AuthUI isLogin={false} {handleSubmit} isLoading={loading} {hideGoogleAuth}>
{#snippet getPasswordAuthAlternative()}
<div class="space-y-3">
{#if ssoState.required}
<div class="flex items-center gap-3">
<ShieldIcon class="size-5 text-blue-600" />
<div class="flex-1">
<p class="mt-1 text-xs text-blue-600">
{$t('settings.auth.login.sso_required')}
</p>
</div>
</div>
{/if}
<Button type="button" variant="outline" class="w-full" onclick={handleSsoLogin}>
{$t('settings.auth.login.sign_in_with_sso', { provider: ssoState.providerName || 'SSO' })}
</Button>
</div>
{/snippet}
<AuthUI
isLogin={false}
{handleSubmit}
isLoading={loading}
{hideGoogleAuth}
getPasswordAuthAlternative={ssoState.available ? getPasswordAuthAlternative : undefined}
>
<div class="ui:flex ui:flex-col ui:gap-6">
<Field.Field>
<Field.Label for="email">{$t('login.fields.email')}</Field.Label>
@@ -160,6 +230,7 @@
id="email"
type="email"
bind:value={fields.email}
oninput={handleEmailChange}
placeholder="[email protected]"
disabled={loading || isEmailPrefilled}
readonly={isEmailPrefilled}
@@ -173,48 +244,50 @@
</Field.Content>
</Field.Field>
<Field.Field>
<Field.Label for="password">{$t('login.fields.password')}</Field.Label>
<Field.Content>
<Password
id="password"
bind:value={fields.password}
placeholder="************"
disabled={loading}
aria-invalid={errors.password ? 'true' : undefined}
autocomplete="new-password"
/>
{#if errors.password}
<Field.Error>{$t(errors.password)}</Field.Error>
{/if}
<Field.Description>{$t('login.fields.password_helper_message')}</Field.Description>
</Field.Content>
</Field.Field>
{#if !ssoState.required}
<Field.Field>
<Field.Label for="password">{$t('login.fields.password')}</Field.Label>
<Field.Content>
<Password
id="password"
bind:value={fields.password}
placeholder="************"
disabled={loading}
aria-invalid={errors.password ? 'true' : undefined}
autocomplete="new-password"
/>
{#if errors.password}
<Field.Error>{$t(errors.password)}</Field.Error>
{/if}
<Field.Description>{$t('login.fields.password_helper_message')}</Field.Description>
</Field.Content>
</Field.Field>
<Field.Field>
<Field.Label for="confirmPassword">{$t('login.fields.confirm_password')}</Field.Label>
<Field.Content>
<Password
id="confirmPassword"
bind:value={fields.confirmPassword}
placeholder="************"
disabled={loading}
aria-invalid={errors.confirmPassword ? 'true' : undefined}
autocomplete="new-password"
/>
{#if errors.confirmPassword}
<Field.Error>{errors.confirmPassword}</Field.Error>
{/if}
</Field.Content>
</Field.Field>
<Field.Field>
<Field.Label for="confirmPassword">{$t('login.fields.confirm_password')}</Field.Label>
<Field.Content>
<Password
id="confirmPassword"
bind:value={fields.confirmPassword}
placeholder="************"
disabled={loading}
aria-invalid={errors.confirmPassword ? 'true' : undefined}
autocomplete="new-password"
/>
{#if errors.confirmPassword}
<Field.Error>{errors.confirmPassword}</Field.Error>
{/if}
</Field.Content>
</Field.Field>
{#if submitError}
<p class="ui:text-sm ui:text-destructive">{submitError}</p>
{#if submitError}
<p class="ui:text-sm ui:text-destructive">{submitError}</p>
{/if}
<Button type="submit" disabled={disableSubmit || loading} {loading} class="ui:w-full">
{$t('login.create_account')}
</Button>
{/if}
<Button type="submit" disabled={disableSubmit || loading} {loading} class="ui:w-full">
{$t('login.create_account')}
</Button>
</div>
</AuthUI>
{/if}
@@ -1,9 +1,10 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { NewsFeedPage } from '$features/course/pages';
import { Button } from '@cio/ui/base/button';
import * as Page from '@cio/ui/base/page';
import { RoleBasedSecurity } from '$features/ui';
import { RefreshPageData, RoleBasedSecurity } from '$features/ui';
import { currentOrg } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import type { AccountOrg } from '$features/app/types';
@@ -25,7 +26,7 @@
<RoleBasedSecurity
allowedRoles={getPageRoles($currentOrg)}
onDenied={() => {
goto(`/courses/${data.courseId}/lessons?next=true`);
goto(resolve(`/courses/${data.courseId}/lessons?next=true`, {}));
}}
>
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
@@ -36,9 +37,12 @@
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<Button class="mr-2" onclick={() => newsfeedApi.openNewFeedModal()}>
{$t('course.navItem.news_feed.heading_button.title')}
</Button>
<div class="flex items-center gap-2">
<Button onclick={() => newsfeedApi.openNewFeedModal()}>
{$t('course.navItem.news_feed.heading_button.title')}
</Button>
<RefreshPageData onRefresh={() => newsfeedApi.list(data.courseId)} />
</div>
</Page.Action>
</Page.Header>
@@ -1,5 +1,6 @@
<script lang="ts">
import { AnalyticsPage } from '$features/course/pages';
import { RefreshPageData } from '$features/ui';
import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
@@ -13,6 +14,9 @@
{$t('analytics.title')}
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<RefreshPageData />
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
@@ -1,7 +1,10 @@
<script lang="ts">
import { AttendancePage } from '$features/course/pages';
import { courseApi } from '$features/course/api';
import { RefreshPageData } from '$features/ui';
import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
import { profile } from '$lib/utils/store/user';
let { data } = $props();
</script>
@@ -13,6 +16,9 @@
{$t('course.navItem.attendance.title')}
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<RefreshPageData onRefresh={() => courseApi.refreshCourse(data.courseId, $profile.id)} />
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
@@ -1,5 +1,6 @@
<script lang="ts">
import { CertificatesPage } from '$features/course/pages';
import { RefreshPageData } from '$features/ui';
import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
</script>
@@ -11,6 +12,9 @@
{$t('course.navItem.certificates.title')}
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<RefreshPageData />
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
@@ -1,11 +1,12 @@
<script lang="ts">
import { LessonsPage } from '$features/course/pages';
import { Button } from '@cio/ui/base/button';
import { RoleBasedSecurity } from '$features/ui';
import { RefreshPageData, RoleBasedSecurity } from '$features/ui';
import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
import { contentCreateStoreUtils, contentEditingStore } from '$features/course/components/content/store';
import { courseApi } from '$features/course/api';
import { profile } from '$lib/utils/store/user';
let { data } = $props();
@@ -38,6 +39,7 @@
>{$t('course.navItem.lessons.add_content')}</Button
>
</RoleBasedSecurity>
<RefreshPageData onRefresh={() => courseApi.refreshCourse(data.courseId, $profile.id)} />
</div>
</Page.Action>
</Page.Header>
@@ -18,7 +18,6 @@
$effect(() => {
if (!data.lesson) return;
const lesson = data.lesson;
if (lessonApi.lesson?.id === lesson.id) return;
// Set lesson data
@@ -13,6 +13,7 @@
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import { courseApi } from '$features/course/api';
import { generateMarksCSV, generateMarksPDF } from '$features/course/utils/marks-utils';
import { RefreshPageData } from '$features/ui';
let { data } = $props();
@@ -97,6 +98,7 @@
</DropdownMenu.Content>
</DropdownMenu.Root>
</RoleBasedSecurity>
<RefreshPageData />
</div>
</Page.Action>
</Page.Header>
@@ -1,12 +1,15 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { goto, invalidateAll } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { IconButton } from '@cio/ui/custom/icon-button';
import { Button } from '@cio/ui/base/button';
import { RoleBasedSecurity } from '$features/ui';
import { RefreshPageData, RoleBasedSecurity } from '$features/ui';
import { t } from '$lib/utils/functions/translations';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import * as Page from '@cio/ui/base/page';
import { courseApi } from '$features/course/api';
import { profile } from '$lib/utils/store/user';
let { data = $bindable(), children } = $props();
@@ -14,17 +17,20 @@
let backUrl = $derived(page.url.searchParams.get('back'));
const handleClick = () => {
const url = page.url.href + '?add=true';
goto(url);
goto(resolve(`${page.url.pathname}?add=true`, {}));
};
const handleBackNavigation = () => {
if (backUrl) {
goto(backUrl);
goto(resolve(backUrl, {}));
} else {
goto(`/courses/${data.courseId}/people`);
goto(resolve(`/courses/${data.courseId}/people`, {}));
}
};
async function refreshPeoplePage() {
await Promise.all([courseApi.refreshCourse(data.courseId, $profile.id), invalidateAll()]);
}
</script>
<Page.Root class="mx-auto w-[90%] md:max-w-3xl">
@@ -42,13 +48,16 @@
</Page.Title>
</Page.HeaderContent>
<Page.Action>
{#if !data.personId}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<Button class="mr-2" onclick={handleClick}>
{$t('course.navItem.people.add')}
</Button>
</RoleBasedSecurity>
{/if}
<div class="flex items-center gap-2">
{#if !data.personId}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<Button onclick={handleClick}>
{$t('course.navItem.people.add')}
</Button>
</RoleBasedSecurity>
{/if}
<RefreshPageData onRefresh={refreshPeoplePage} />
</div>
</Page.Action>
</Page.Header>
@@ -3,9 +3,13 @@
import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
import { Button } from '@cio/ui/base/button';
import { courseApi } from '$features/course/api';
import { profile } from '$lib/utils/store/user';
import { RefreshPageData } from '$features/ui';
let settingsComponent: CourseSettingsPage | null = $state(null);
let isSaving = $state(false);
let { data } = $props();
async function handleSave() {
isSaving = true;
@@ -25,9 +29,12 @@
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<Button variant="secondary" loading={isSaving} onclick={handleSave}>
{$t('course.navItem.settings.save')}
</Button>
<div class="flex items-center gap-2">
<Button variant="secondary" loading={isSaving} onclick={handleSave}>
{$t('course.navItem.settings.save')}
</Button>
<RefreshPageData disabled={isSaving} onRefresh={() => courseApi.refreshCourse(data.courseId, $profile.id)} />
</div>
</Page.Action>
</Page.Header>
<Page.Body>
@@ -1,6 +1,6 @@
<script lang="ts">
import { SubmissionsPage } from '$features/course/pages';
import { RoleBasedSecurity } from '$features/ui';
import { RefreshPageData, RoleBasedSecurity } from '$features/ui';
import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
@@ -15,6 +15,9 @@
{$t('course.navItem.submissions.title')}
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<RefreshPageData />
</Page.Action>
</Page.Header>
<Page.Body>
@@ -0,0 +1,6 @@
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load: PageLoad = ({ params, url }) => {
throw redirect(307, `/org/${params.slug}/automation/mcp${url.search}`);
};
@@ -1,22 +1,22 @@
<script>
import { AutomationPage } from '$features/automation/pages';
import { ApiAutomationPage } 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>
<title>{t.get('automation.tabs.api')} - ClassroomIO</title>
</svelte:head>
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('automation.title')}</Page.Title>
<Page.Subtitle>{$t('automation.subtitle')}</Page.Subtitle>
<Page.Title>{$t('automation.tabs.api')}</Page.Title>
<Page.Subtitle>{$t('automation.coming_soon')}</Page.Subtitle>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<AutomationPage />
<ApiAutomationPage />
{/snippet}
</Page.Body>
@@ -0,0 +1,23 @@
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
export const load = async ({ parent, cookies }) => {
const { orgId } = await parent();
if (!orgId) {
return { keys: [], usage: null };
}
const headers = getApiHeaders(cookies, orgId);
const [keysResponse, usageResponse] = await Promise.all([
classroomio.organization.automation.keys.$get({ query: { type: 'mcp' } }, headers),
classroomio.organization.automation.usage.$get({ query: { type: 'mcp' } }, headers)
]);
const [keysData, usageData] = await Promise.all([keysResponse.json(), usageResponse.json()]);
return {
keys: keysData.success ? keysData.data : [],
usage: usageData.success ? usageData.data : null
};
};
@@ -0,0 +1,34 @@
<script lang="ts">
import { McpAutomationPage } from '$features/automation/pages';
import { automationApi } from '$features/automation/api/automation.svelte';
import { t } from '$lib/utils/functions/translations';
import * as Page from '@cio/ui/base/page';
let { data } = $props();
$effect(() => {
if (!data || automationApi.isFetched) return;
automationApi.keys = data.keys;
automationApi.usage = data.usage;
automationApi.isFetched = true;
});
</script>
<svelte:head>
<title>{t.get('automation.tabs.mcp')} - ClassroomIO</title>
</svelte:head>
<Page.Root class="mx-auto w-full max-w-4xl">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('automation.tabs.mcp')}</Page.Title>
<Page.Subtitle>{$t('automation.subtitle')}</Page.Subtitle>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<McpAutomationPage />
{/snippet}
</Page.Body>
</Page.Root>
@@ -0,0 +1,22 @@
<script>
import { ZapierAutomationPage } 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.tabs.zapier')} - ClassroomIO</title>
</svelte:head>
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('automation.tabs.zapier')}</Page.Title>
<Page.Subtitle>{$t('automation.coming_soon')}</Page.Subtitle>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<ZapierAutomationPage />
{/snippet}
</Page.Body>
@@ -13,11 +13,13 @@
let searchValue = $state('');
let selectedId: string = $state('0');
let selectedOrder = $state<'asc' | 'desc'>('desc');
let selectedTags = $state<string[]>([]);
let hasInitializedFilters = $state(false);
let isFiltering = $state(false);
const validSortValues = new Set(COURSE_SORT_OPTIONS.map((option) => option.value));
const validSortOrders = new Set(['asc', 'desc']);
$effect(() => {
if (data.courses) {
@@ -37,6 +39,27 @@
return '0';
}
function normalizeSortOrder(sortOrder: string | null): 'asc' | 'desc' {
if (sortOrder && validSortOrders.has(sortOrder)) {
return sortOrder as 'asc' | 'desc';
}
return 'desc';
}
function getLegacySortState(sortValue: string | null) {
switch (sortValue) {
case '3':
return { selectedId: '0', selectedOrder: 'asc' as const };
case '4':
return { selectedId: '1', selectedOrder: 'asc' as const };
case '5':
return { selectedId: '2', selectedOrder: 'asc' as const };
default:
return null;
}
}
function updateFiltersUrl() {
if (!browser || !hasInitializedFilters) {
return;
@@ -56,6 +79,12 @@
nextUrl.searchParams.delete('sort');
}
if (selectedOrder !== 'desc') {
nextUrl.searchParams.set('order', selectedOrder);
} else {
nextUrl.searchParams.delete('order');
}
const nextPath = `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`;
window.history.replaceState(window.history.state, '', nextPath);
}
@@ -65,7 +94,8 @@
return;
}
localStorage.setItem('classroomio_filter_course_key', selectedId);
localStorage.setItem('classroomio_filter_course_sort_key', selectedId);
localStorage.setItem('classroomio_filter_course_order_key', selectedOrder);
updateFiltersUrl();
});
@@ -94,6 +124,7 @@
async function clearFilters() {
selectedId = '0';
selectedOrder = 'desc';
if (selectedTags.length === 0) {
return;
@@ -111,15 +142,27 @@
return false;
});
const sortedCourses = [...filteredCourses];
if (selectedId === '0') {
return filteredCourses.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
return sortedCourses.sort((a, b) =>
selectedOrder === 'asc'
? new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
: new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
} else if (selectedId === '1') {
return filteredCourses.sort((a, b) => (b.isPublished ? 0 : 1) - (a.isPublished ? 0 : 1));
return sortedCourses.sort((a, b) =>
selectedOrder === 'asc'
? Number(a.isPublished) - Number(b.isPublished)
: Number(b.isPublished) - Number(a.isPublished)
);
} else if (selectedId === '2') {
return filteredCourses.sort((a, b) => b.lessonCount - a.lessonCount);
return sortedCourses.sort((a, b) =>
selectedOrder === 'asc' ? a.lessonCount - b.lessonCount : b.lessonCount - a.lessonCount
);
}
return filteredCourses;
return sortedCourses;
});
onMount(() => {
@@ -129,9 +172,32 @@
$courseMetaDeta.view = courseView;
}
const sortFromUrl = normalizeSortValue(new URLSearchParams(window.location.search).get('sort'));
const sortFromStorage = normalizeSortValue(localStorage.getItem('classroomio_filter_course_key'));
selectedId = sortFromUrl !== '0' ? sortFromUrl : sortFromStorage;
const searchParams = new URLSearchParams(window.location.search);
const legacySortFromUrl = getLegacySortState(searchParams.get('sort'));
const legacySortFromStorage = getLegacySortState(localStorage.getItem('classroomio_filter_course_key'));
const oldSortFromStorage = normalizeSortValue(localStorage.getItem('classroomio_filter_course_key'));
const sortFromUrl = normalizeSortValue(searchParams.get('sort'));
const sortFromStorage = normalizeSortValue(localStorage.getItem('classroomio_filter_course_sort_key'));
const orderFromUrl = normalizeSortOrder(searchParams.get('order'));
const orderFromStorage = normalizeSortOrder(localStorage.getItem('classroomio_filter_course_order_key'));
if (legacySortFromUrl) {
selectedId = legacySortFromUrl.selectedId;
selectedOrder = legacySortFromUrl.selectedOrder;
} else if (sortFromUrl !== '0' || orderFromUrl !== 'desc') {
selectedId = sortFromUrl;
selectedOrder = orderFromUrl;
} else if (legacySortFromStorage) {
selectedId = legacySortFromStorage.selectedId;
selectedOrder = legacySortFromStorage.selectedOrder;
} else if (sortFromStorage !== '0' || orderFromStorage !== 'desc') {
selectedId = sortFromStorage;
selectedOrder = orderFromStorage;
} else {
selectedId = oldSortFromStorage;
selectedOrder = 'desc';
}
hasInitializedFilters = true;
updateFiltersUrl();
});
@@ -156,6 +222,7 @@
{#snippet filterControls()}
<CourseFilterPopover
bind:selectedId
bind:selectedOrder
{selectedTags}
tagGroups={data.tagGroups}
{isFiltering}
+22 -104
View File
@@ -4,101 +4,19 @@ import adapterAuto from '@sveltejs/adapter-auto';
import adapterNode from '@sveltejs/adapter-node';
import path from 'path';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
import { getCspDomains } from './src/lib/utils/csp-domains.js';
const useNodeAdapter = process.env.PUBLIC_IS_SELFHOSTED === 'true';
const API_ORIGIN = process.env.PUBLIC_SERVER_URL;
const isSelfHosted = process.env.PUBLIC_IS_SELFHOSTED === 'true';
const IS_CLOUDFLARE = process.env.CI_ENVIRONMENT === 'cloudflare';
const adapterCloudflare = IS_CLOUDFLARE ? (await import('@sveltejs/adapter-cloudflare')).default : null;
/**
* Parse comma-separated domain list from env.
* Normalizes to https:// URLs. Returns null when env var is unset (use defaults).
*/
function parseDomainsFromEnv(envVar) {
const value = process.env[envVar];
if (!value || typeof value !== 'string') return null;
return value
.split(',')
.map((d) => d.trim())
.filter(Boolean)
.map((d) => (d.startsWith('http://') || d.startsWith('https://') ? d : `https://${d}`));
}
const ALLOWED_EXTERNAL_DOMAINS = parseDomainsFromEnv('ALLOWED_EXTERNAL_DOMAINS');
const ALLOWED_SCRIPT_SRC = parseDomainsFromEnv('CSP_SCRIPT_SRC_DOMAINS');
const ALLOWED_STYLE_SRC = parseDomainsFromEnv('CSP_STYLE_SRC_DOMAINS');
const ALLOWED_CONNECT_SRC = parseDomainsFromEnv('CSP_CONNECT_SRC_DOMAINS');
const ALLOWED_FRAME_SRC = parseDomainsFromEnv('CSP_FRAME_SRC_DOMAINS');
const ALLOWED_FONT_SRC = parseDomainsFromEnv('CSP_FONT_SRC_DOMAINS');
const defaultScriptSrcDomains = [
'https://assets.cdn.clsrio.com',
'https://cdnjs.cloudflare.com',
'https://*.posthog.com',
'https://*.senja.io',
'https://umami.hz.oncws.com',
'https://www.youtube.com',
'https://youtube.com',
'https://google.com'
];
const defaultStyleSrcDomains = [
'https://cdn.plyr.io',
'https://unpkg.com/[email protected]/dist/katex.min.css',
'https://assets.cdn.clsrio.com/eqneditor_1.css',
'https://fonts.googleapis.com'
];
const defaultConnectSrcDomains = [
'https://*.classroomio.com',
'https://classroomio.com',
'https://app.classroomio.com',
'https://api.classroomio.com',
'https://pgrest.classroomio.com',
'https://play.classroomio.com',
'wss://*.classroomio.com',
'https://assets.cdn.clsrio.com',
'https://cdn.plyr.io',
'https://*.posthog.com',
'https://umami.hz.oncws.com',
'https://*.r2.cloudflarestorage.com',
'https://*.senja.io',
'https://*.ytimg.com',
'https://noembed.com'
];
const defaultFrameSrcDomains = [
'https://www.youtube.com',
'https://youtube.com',
'https://www.youtube-nocookie.com',
'https://www.google.com',
'https://google.com'
];
const defaultFontSrcDomains = ['https://fonts.gstatic.com', 'https://cdn.plyr.io'];
// Self-hosted: use ONLY env vars, no defaults (strict CISO allowlists, data residency).
// SaaS: use defaults when env vars are unset (backward compatible).
const scriptSrcDomains = useNodeAdapter
? (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_SCRIPT_SRC ?? [])
: (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_SCRIPT_SRC ?? defaultScriptSrcDomains);
const styleSrcDomains = useNodeAdapter
? (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_STYLE_SRC ?? [])
: (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_STYLE_SRC ?? defaultStyleSrcDomains);
const connectSrcDomains = useNodeAdapter
? (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_CONNECT_SRC ?? [])
: (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_CONNECT_SRC ?? defaultConnectSrcDomains);
const frameSrcDomains = useNodeAdapter
? (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_FRAME_SRC ?? [])
: (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_FRAME_SRC ?? defaultFrameSrcDomains);
const fontSrcDomains = useNodeAdapter
? (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_FONT_SRC ?? [])
: (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_FONT_SRC ?? defaultFontSrcDomains);
const csp = getCspDomains(isSelfHosted, process.env.PUBLIC_SERVER_URL);
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors
preprocess: [vitePreprocess({})],
kit: {
adapter: IS_CLOUDFLARE ? adapterCloudflare() : useNodeAdapter ? adapterNode() : adapterAuto(),
adapter: IS_CLOUDFLARE ? adapterCloudflare() : isSelfHosted ? adapterNode() : adapterAuto(),
alias: {
$lib: path.resolve('./src/lib'),
$features: path.resolve('./src/lib/features'),
@@ -117,20 +35,20 @@ const config = {
mode: 'auto',
directives: {
'default-src': ['self'],
'script-src': ['self', ...scriptSrcDomains, 'unsafe-hashes', 'unsafe-eval'],
'style-src': ['self', 'unsafe-inline', ...styleSrcDomains],
'style-src-elem': ['self', 'unsafe-inline', ...styleSrcDomains],
'font-src': ['self', ...fontSrcDomains],
'img-src': ['self', 'data:', 'https:', 'blob:', 'http://localhost:9000'],
'media-src': ['self', 'https:', 'data:', 'blob:', 'http://localhost:9000'],
'frame-src': ['self', ...frameSrcDomains],
'script-src': ['self', ...csp.scriptSrc, 'unsafe-hashes', 'unsafe-eval'],
'style-src': ['self', 'unsafe-inline', ...csp.styleSrc],
'style-src-elem': ['self', 'unsafe-inline', ...csp.styleSrc],
'font-src': ['self', ...csp.fontSrc],
'img-src': ['self', 'data:', ...csp.mediaSrc, 'blob:', 'http://localhost:9000'],
'media-src': ['self', ...csp.mediaSrc, 'data:', 'blob:', 'http://localhost:9000'],
'frame-src': ['self', ...csp.frameSrc],
'connect-src': [
'self',
'blob:',
'http://localhost:3002',
'http://localhost:9000',
...(API_ORIGIN ? [API_ORIGIN] : []),
...connectSrcDomains
...(csp.apiOrigin ? [csp.apiOrigin] : []),
...csp.connectSrc
],
'worker-src': ['self', 'blob:'],
'object-src': ['none'],
@@ -141,20 +59,20 @@ const config = {
},
reportOnly: {
'default-src': ['self'],
'script-src': ['self', ...scriptSrcDomains, 'unsafe-hashes', 'unsafe-eval'],
'style-src': ['self', 'unsafe-inline', ...styleSrcDomains],
'style-src-elem': ['self', 'unsafe-inline', ...styleSrcDomains],
'font-src': ['self', ...fontSrcDomains],
'img-src': ['self', 'data:', 'https:', 'blob:', 'http://localhost:9000'],
'media-src': ['self', 'https:', 'data:', 'blob:', 'http://localhost:9000'],
'frame-src': ['self', ...frameSrcDomains],
'script-src': ['self', ...csp.scriptSrc, 'unsafe-hashes', 'unsafe-eval'],
'style-src': ['self', 'unsafe-inline', ...csp.styleSrc],
'style-src-elem': ['self', 'unsafe-inline', ...csp.styleSrc],
'font-src': ['self', ...csp.fontSrc],
'img-src': ['self', 'data:', ...csp.mediaSrc, 'blob:', 'http://localhost:9000'],
'media-src': ['self', ...csp.mediaSrc, 'data:', 'blob:', 'http://localhost:9000'],
'frame-src': ['self', ...csp.frameSrc],
'connect-src': [
'self',
'blob:',
'http://localhost:3002',
'http://localhost:9000',
...(API_ORIGIN ? [API_ORIGIN] : []),
...connectSrcDomains
...(csp.apiOrigin ? [csp.apiOrigin] : []),
...csp.connectSrc
],
'worker-src': ['self', 'blob:'],
'object-src': ['none'],
+209 -52
View File
@@ -132,6 +132,7 @@ Current tools:
- `list_org_courses`
- `get_course_structure`
- `reorder_course_content`
- `update_course_landing_page`
- `create_course_draft`
- `create_course_draft_from_course`
@@ -172,7 +173,42 @@ Drafts support:
- safer updates to existing courses
- auditability of AI-generated structure
## Create a New Course
Lesson authoring rule:
- `lessonLanguages[].content` should contain only the lesson body HTML
- do not include the lesson title
- do not use `h1` or `h2` in lesson content
- start headings at `h3`
- ClassroomIO already renders the lesson title in the course UI
## After Publish
Once a draft has been published, the live course becomes the safer source of truth.
Recommended agent rule:
- if the draft is still `DRAFT`, continue editing that draft
- if the draft is `PUBLISHED`, do not keep reusing it for major follow-up edits
- instead, read the live course and create a fresh draft from that course
Recommended post-publish sequence:
1. `get_course_structure`
2. `create_course_draft_from_course`
3. `update_course_draft`
4. `publish_course_draft_to_existing_course`
Why:
- teachers may edit the live course in the UI after publish
- another agent session may have changed the course
- reusing an old published draft can overwrite newer live changes
For exercises on an already-published course, prefer the direct exercise tools instead of creating a whole new draft when the user only wants to add or revise exercises.
## User Flows
### Flow 1: Create a new course from a prompt
Typical flow:
@@ -195,26 +231,20 @@ Publish returns:
- use an explicit `bannerImageUrl`
- generate a random Unsplash-based banner from the course title or a custom query
## Update an Existing Course
### Flow 2: Update a landing page with AI-generated copy and media
When the course already exists, use the live course as the source of truth.
User says:
Recommended sequence:
```text
Rewrite this course landing page to feel more premium, generate a new cover image, and add three believable reviews.
```
1. `list_org_courses` if the agent needs to resolve the course ID by name
2. `get_course_structure`
3. `create_course_draft_from_course`
4. `update_course_draft`
5. `publish_course_draft_to_existing_course`
Expected tool sequence:
This avoids applying broad, blind changes directly to the live course.
1. Agent calls `list_org_courses` if it needs to resolve the course ID by name first.
2. Agent calls `update_course_landing_page`.
## Update a Course Landing Page
Use `update_course_landing_page` when the user wants AI help with sales-page style content on an
already-existing course.
This tool can update:
What the landing-page tool can update:
- title
- short description
@@ -224,33 +254,167 @@ This tool can update:
- pricing fields
- instructor section
- reviews
- the landing-page image
- landing page image
For images, the tool supports either:
The tool can either:
- `imageUrl` for an explicit cover image
- `generateImage` and `imageQuery` for a random Unsplash-derived image
- use an explicit `imageUrl`
- or generate a random Unsplash-based image with `generateImage` and `imageQuery`
## After Publish
### Flow 3: Create a draft from a PDF
Once a draft has been published, the live course becomes the safer source of truth.
User says:
Recommended agent rule:
```text
I have my course in a PDF. Extract it and turn it into a ClassroomIO course draft.
```
- if the draft is still `DRAFT`, continue editing that draft
- if the draft is `PUBLISHED`, do not keep reusing it for major follow-up edits
- instead, read the live course and create a fresh draft from that course
Expected tool sequence:
Why:
1. Agent reads the PDF itself.
2. Agent restructures the content into normalized JSON.
3. Agent calls `create_course_draft`.
4. User reviews the draft.
5. Agent calls `update_course_draft` if needed.
6. User approves.
7. Agent calls `publish_course_draft`.
- teachers may edit the live course in the UI after publish
- another agent session may have changed the course
- reusing an old published draft can overwrite newer live changes
Important:
## Exercises on Live Courses
- the PDF does not need to be uploaded to MCP
- the MCP package only receives structured JSON
If the user only wants to add or revise exercises on a published course, prefer the direct
exercise tools instead of creating a whole new draft:
### Flow 4: Tag a draft before publish
User says:
```text
Add tags for algebra, beginner, and exponential growth to this draft.
```
Expected tool sequence:
1. Agent calls `tag_course_draft`.
2. User reviews the updated draft tags.
3. Agent later calls `publish_course_draft`.
Result:
- the draft stores tag names
- publish ensures the tags exist in ClassroomIO
- publish assigns those tags to the live course
### Flow 5: Update an existing course safely
User says:
```text
Take my existing Algebra course, reorganize it, and rewrite the lesson content for a beginner audience.
```
When the course already exists, use the live course as the source of truth.
Expected tool sequence:
1. `list_org_courses` if the agent needs to resolve the course ID by name
2. Agent calls `get_course_structure` with the existing `courseId`.
3. Agent optionally calls `create_course_draft_from_course` to persist a seeded draft.
4. Agent updates the draft with the new structure and content using `update_course_draft`.
5. User reviews the changes.
6. Agent calls `publish_course_draft_to_existing_course`.
What this publish does today:
- updates the existing course title, description, type, and metadata
- can set a generated course cover from Unsplash
- returns the public `courseUrl`
- updates existing sections when the draft keeps their IDs
- updates existing lessons when the draft keeps their IDs
- creates new sections and lessons for draft items without matching live IDs
- upserts lesson language content
- merges draft tags into the existing course
### Flow 6: Reorder live course content
User says:
```text
Move the quiz before lesson 2 and put the recap lesson into the last section.
```
Use `reorder_course_content` when the live course only needs structural moves:
- reorder sections
- reorder lessons or exercises within a section
- move lessons or exercises between sections
This is the fast path for live structure edits and avoids creating a draft when lesson content is not changing.
Expected tool sequence:
1. Agent calls `reorder_course_content`.
Use this direct path when the live course only needs section ordering or lesson or exercise moves between sections.
It avoids the full draft round-trip for simple structural changes.
### Flow 7: Add exercises to a live course
User says:
```text
Add a 5-question multiple-choice exercise to lesson 3.
```
Expected tool sequence:
1. Agent calls `list_course_exercises` if it needs current context.
2. Agent calls `create_course_exercise` for a brand new exercise.
3. Agent calls `update_course_exercise` for later revisions.
Use this direct path when the course is already live and the change is exercise-specific.
What it does not do today:
- automatically delete sections or lessons that are absent from the draft
- automatically delete lesson locales that are absent from the draft
That behavior is intentional. The current update path is non-destructive by default.
## How Existing-Course Updates Work
When a draft is seeded from a live course:
- section `externalId` values are the real section IDs
- lesson `externalId` values are the real lesson IDs
As long as the agent preserves those IDs while editing the draft, publish-to-existing-course can map draft items back to the live records and update them safely.
If the agent adds a brand new section or lesson, it should assign a new synthetic `externalId`. ClassroomIO will create that record on publish.
## Input Contract Notes
For large updates, the agent should:
1. call `get_course_structure`
2. keep existing `externalId` values for records it wants to update
3. only generate new `externalId` values for new records
4. avoid deleting records from the draft unless the user explicitly wants omitted content to remain untouched
Publish input also supports:
- `bannerImageUrl`: use this exact course cover
- `bannerImageQuery`: search Unsplash with this query
- `generateBannerImage`: fetch a random Unsplash image using the course title or query
Tagging tools use:
- `tagNames`: human-readable tag names, not tag IDs
- `mode: "merge" | "replace"`
- `groupName` optional, defaults to `Automation`
The API creates missing tags automatically when needed.
Exercise tools use the live course directly:
- `list_course_exercises`
- `get_course_exercise`
@@ -258,8 +422,6 @@ exercise tools instead of creating a whole new draft:
- `create_course_exercise_from_template`
- `update_course_exercise`
Use a draft only when the agent is restructuring broader course content.
Supported `questionTypeId` values for exercise payloads:
- `1` `RADIO` - Single answer
@@ -275,29 +437,24 @@ Supported `questionTypeId` values for exercise payloads:
- `11` `HOTSPOT` - Hotspot
- `12` `LINK` - Links
## Tagging
## Operational Notes
The MCP supports tagging at two levels:
- `create_course_draft_from_course` may add warnings if a live course uses legacy lesson notes instead of localized lesson content
- if a course has ungrouped lessons, the seeded draft may add a synthetic `Ungrouped` section so the structure stays valid
- published drafts are treated as finalized snapshots
- `tag_course_draft`
- set or revise tags before publish
- `tag_courses`
- tag one or more live courses
## Recommended Agent Behavior
The live-course tagging tool supports merge-or-replace behavior. Use merge when the user wants to
add tags without removing existing ones.
For small edits, an agent can still read the course structure and modify only the affected items in the draft.
## PDF-Based Authoring
For broad revisions, prefer this sequence:
If a user wants to turn a PDF into a course:
1. `get_course_structure`
2. `create_course_draft_from_course`
3. `update_course_draft`
4. `publish_course_draft_to_existing_course`
1. the agent reads and extracts the PDF itself
2. the agent converts the content into normalized course JSON
3. the agent calls `create_course_draft`
4. the user reviews the draft
5. the agent publishes it
The PDF itself is not uploaded to the MCP server.
That keeps major AI-assisted edits reviewable and explicit.
<Callout title="Operational Rule">
Keep published drafts for history and auditability. Do not treat omission from a draft as an
@@ -31,7 +31,7 @@ cd classroomio
cp .env.example .env
```
Open `.env` and set `PUBLIC_SERVER_URL`, `TRUSTED_ORIGINS`, `BETTER_AUTH_SECRET`, and `ALLOWED_EXTERNAL_DOMAINS` for your domain. For local development the defaults work as-is.
Open `.env` and set `PUBLIC_SERVER_URL`, `TRUSTED_ORIGINS`, and `BETTER_AUTH_SECRET` for your domain. For local development the defaults work as-is.
3. Start the full stack:
@@ -73,10 +73,23 @@ Docker self-hosting uses a single root `.env` file. Every `docker compose` comma
| `PUBLIC_IS_SELFHOSTED` | Self-hosted mode flag | `true` |
| `PRIVATE_SERVER_URL` | Internal API URL for SSR (Docker network) | `http://api:3081` |
| `DASHBOARD_ORIGIN` | Dashboard origin for invite links, email links, etc. | `http://localhost:3082` |
| `ALLOWED_EXTERNAL_DOMAINS` | CSP allowed domains (comma-separated) | — |
#### CSP Domains (Runtime)
CSP domain variables are **runtime** — set them in your `.env` or `docker-compose.yaml` `environment:` section. No image rebuild is needed when changing them.
| Variable | Description |
|----------|-------------|
| `ALLOWED_EXTERNAL_DOMAINS` | Overrides all per-directive CSP vars (comma-separated) |
| `CSP_SCRIPT_SRC_DOMAINS` | Domains allowed in `script-src` |
| `CSP_STYLE_SRC_DOMAINS` | Domains allowed in `style-src` |
| `CSP_CONNECT_SRC_DOMAINS` | Domains allowed in `connect-src` |
| `CSP_FRAME_SRC_DOMAINS` | Domains allowed in `frame-src` |
| `CSP_FONT_SRC_DOMAINS` | Domains allowed in `font-src` |
| `CSP_MEDIA_SRC_DOMAINS` | Domains allowed in `img-src` and `media-src` |
<Callout type="warn" title="CSP Domains">
When `PUBLIC_IS_SELFHOSTED=true`, the dashboard has **no default** CSP domains. You must set `ALLOWED_EXTERNAL_DOMAINS` with your CDN, API, video, and font domains.
When `PUBLIC_IS_SELFHOSTED=true`, the dashboard starts with only `'self'` allowed. Set `ALLOWED_EXTERNAL_DOMAINS` (or the per-directive vars) with your CDN, API, video, and font domains. Since these are runtime env vars, you can change them by restarting the container — no rebuild required.
</Callout>
#### Optional — Email
@@ -229,7 +242,7 @@ PRIVATE_SERVER_URL="http://${{api.RAILWAY_PRIVATE_DOMAIN}}:8080"
PUBLIC_SERVER_URL="https://api.yourdomain.com"
```
Optional: add `PUBLIC_APP_TITLE`, `PUBLIC_APP_DESCRIPTION`, `PUBLIC_OG_IMAGE_URL` for custom branding.
Optional: add `PUBLIC_APP_TITLE`, `PUBLIC_APP_DESCRIPTION`, `PUBLIC_OG_IMAGE_URL` for custom branding. You can also use per-directive CSP vars (`CSP_SCRIPT_SRC_DOMAINS`, `CSP_MEDIA_SRC_DOMAINS`, etc.) instead of `ALLOWED_EXTERNAL_DOMAINS` for finer control.
<Callout title="PRIVATE_SERVER_KEY">
`PRIVATE_SERVER_KEY` must be the same value in both API and dashboard. It authenticates server-side dashboard requests to the API.
+4 -1
View File
@@ -13,9 +13,12 @@ RUN pnpm install --frozen-lockfile --ignore-scripts --shamefully-hoist --filter
RUN find . -name 'tsconfig.tsbuildinfo' -delete
RUN pnpm -r --filter @cio/api... build
COPY docker/entrypoint-api.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
EXPOSE 3081
ENV NODE_ENV=production \
PORT=3081
CMD ["pnpm", "--filter", "@cio/api", "start"]
ENTRYPOINT ["/app/entrypoint.sh"]
+1 -17
View File
@@ -5,13 +5,6 @@ WORKDIR /app
ENV CI=true
ARG PUBLIC_IS_SELFHOSTED=true
ARG PUBLIC_SERVER_URL
ARG ALLOWED_EXTERNAL_DOMAINS
ARG CSP_SCRIPT_SRC_DOMAINS
ARG CSP_STYLE_SRC_DOMAINS
ARG CSP_CONNECT_SRC_DOMAINS
ARG CSP_FRAME_SRC_DOMAINS
ARG CSP_FONT_SRC_DOMAINS
RUN corepack enable && corepack prepare [email protected] --activate
@@ -19,17 +12,8 @@ COPY . .
RUN pnpm install --frozen-lockfile --ignore-scripts --filter @cio/dashboard...
RUN find . -name 'tsconfig.tsbuildinfo' -delete
RUN echo "Dashboard build PUBLIC_IS_SELFHOSTED=${PUBLIC_IS_SELFHOSTED}" && \
echo "Dashboard build PUBLIC_SERVER_URL=${PUBLIC_SERVER_URL}" && \
NODE_OPTIONS="--max-old-space-size=3072" \
RUN NODE_OPTIONS="--max-old-space-size=3072" \
PUBLIC_IS_SELFHOSTED="${PUBLIC_IS_SELFHOSTED}" \
PUBLIC_SERVER_URL="${PUBLIC_SERVER_URL}" \
ALLOWED_EXTERNAL_DOMAINS="${ALLOWED_EXTERNAL_DOMAINS}" \
CSP_SCRIPT_SRC_DOMAINS="${CSP_SCRIPT_SRC_DOMAINS}" \
CSP_STYLE_SRC_DOMAINS="${CSP_STYLE_SRC_DOMAINS}" \
CSP_CONNECT_SRC_DOMAINS="${CSP_CONNECT_SRC_DOMAINS}" \
CSP_FRAME_SRC_DOMAINS="${CSP_FRAME_SRC_DOMAINS}" \
CSP_FONT_SRC_DOMAINS="${CSP_FONT_SRC_DOMAINS}" \
pnpm -r --filter @cio/dashboard... build
RUN test -f apps/dashboard/build/index.js || (echo "ERROR: Dashboard build output not found at apps/dashboard/build/index.js" && exit 1)
+8 -26
View File
@@ -72,23 +72,6 @@ services:
profiles:
- minio
db-init:
build:
context: ..
dockerfile: docker/Dockerfile.api
container_name: cio-db-init
depends_on:
postgres:
condition: service_healthy
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-classroomio}
PRIVATE_DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-classroomio}
command:
- sh
- -c
- pnpm --filter @cio/db db:setup
restart: "no"
api:
build:
context: ..
@@ -101,8 +84,6 @@ services:
condition: service_healthy
redis:
condition: service_healthy
db-init:
condition: service_completed_successfully
environment:
NODE_ENV: production
PORT: 3081
@@ -137,6 +118,7 @@ services:
LICENSE_KEY: ${LICENSE_KEY:-}
UNSPLASH_API_KEY: ${UNSPLASH_API_KEY:-}
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-local-dev-only-secret-change-this}
SKIP_DB_SETUP: ${SKIP_DB_SETUP:-}
restart: unless-stopped
dashboard:
@@ -145,13 +127,6 @@ services:
dockerfile: docker/Dockerfile.dashboard
args:
PUBLIC_IS_SELFHOSTED: ${PUBLIC_IS_SELFHOSTED:-true}
PUBLIC_SERVER_URL: ${PUBLIC_SERVER_URL:-http://localhost:3081}
ALLOWED_EXTERNAL_DOMAINS: ${ALLOWED_EXTERNAL_DOMAINS:-}
CSP_SCRIPT_SRC_DOMAINS: ${CSP_SCRIPT_SRC_DOMAINS:-}
CSP_STYLE_SRC_DOMAINS: ${CSP_STYLE_SRC_DOMAINS:-}
CSP_CONNECT_SRC_DOMAINS: ${CSP_CONNECT_SRC_DOMAINS:-}
CSP_FRAME_SRC_DOMAINS: ${CSP_FRAME_SRC_DOMAINS:-}
CSP_FONT_SRC_DOMAINS: ${CSP_FONT_SRC_DOMAINS:-}
container_name: cio-dashboard
ports:
- "3082:3082"
@@ -169,6 +144,13 @@ services:
PRIVATE_APP_HOST: ${PRIVATE_APP_HOST:-localhost}
PRIVATE_APP_SUBDOMAINS: ${PRIVATE_APP_SUBDOMAINS:-app}
AUTH_BEARER_TOKEN: ${AUTH_BEARER_TOKEN:-}
ALLOWED_EXTERNAL_DOMAINS: ${ALLOWED_EXTERNAL_DOMAINS:-}
CSP_SCRIPT_SRC_DOMAINS: ${CSP_SCRIPT_SRC_DOMAINS:-}
CSP_STYLE_SRC_DOMAINS: ${CSP_STYLE_SRC_DOMAINS:-}
CSP_CONNECT_SRC_DOMAINS: ${CSP_CONNECT_SRC_DOMAINS:-}
CSP_FRAME_SRC_DOMAINS: ${CSP_FRAME_SRC_DOMAINS:-}
CSP_FONT_SRC_DOMAINS: ${CSP_FONT_SRC_DOMAINS:-}
CSP_MEDIA_SRC_DOMAINS: ${CSP_MEDIA_SRC_DOMAINS:-}
restart: unless-stopped
volumes:
-7
View File
@@ -42,13 +42,6 @@ echo "Dashboard build arg PUBLIC_IS_SELFHOSTED=${PUBLIC_IS_SELFHOSTED}"
docker build \
-f docker/Dockerfile.dashboard \
--build-arg PUBLIC_IS_SELFHOSTED=${PUBLIC_IS_SELFHOSTED} \
--build-arg PUBLIC_SERVER_URL=${PUBLIC_SERVER_URL:-} \
--build-arg ALLOWED_EXTERNAL_DOMAINS="${ALLOWED_EXTERNAL_DOMAINS:-}" \
--build-arg CSP_SCRIPT_SRC_DOMAINS="${CSP_SCRIPT_SRC_DOMAINS:-}" \
--build-arg CSP_STYLE_SRC_DOMAINS="${CSP_STYLE_SRC_DOMAINS:-}" \
--build-arg CSP_CONNECT_SRC_DOMAINS="${CSP_CONNECT_SRC_DOMAINS:-}" \
--build-arg CSP_FRAME_SRC_DOMAINS="${CSP_FRAME_SRC_DOMAINS:-}" \
--build-arg CSP_FONT_SRC_DOMAINS="${CSP_FONT_SRC_DOMAINS:-}" \
-t ${DOCKERHUB_USERNAME}/dashboard:${VERSION} \
-t ${DOCKERHUB_USERNAME}/dashboard:latest \
.
+1 -1
View File
@@ -158,7 +158,7 @@ The GitHub Actions workflow builds for both `linux/amd64` and `linux/arm64` plat
### Build failures
- Check Dockerfile syntax
- Ensure all build arguments are provided
- The only required build argument is `PUBLIC_IS_SELFHOSTED` (defaults to `true`)
- Check available disk space: `docker system df`
### Clean up old images
+2 -1
View File
@@ -25,7 +25,8 @@ All Docker services read from a single root `.env` file. See [`.env.example`](..
Key points:
- **Required:** `PUBLIC_SERVER_URL`, `TRUSTED_ORIGINS`, `BETTER_AUTH_SECRET`, `PUBLIC_IS_SELFHOSTED=true`, `ALLOWED_EXTERNAL_DOMAINS`, `DASHBOARD_ORIGIN`.
- **Required:** `PUBLIC_SERVER_URL`, `TRUSTED_ORIGINS`, `BETTER_AUTH_SECRET`, `PUBLIC_IS_SELFHOSTED=true`, `DASHBOARD_ORIGIN`.
- **CSP (runtime):** `ALLOWED_EXTERNAL_DOMAINS` (overrides all) or per-directive: `CSP_SCRIPT_SRC_DOMAINS`, `CSP_STYLE_SRC_DOMAINS`, `CSP_CONNECT_SRC_DOMAINS`, `CSP_FRAME_SRC_DOMAINS`, `CSP_FONT_SRC_DOMAINS`, `CSP_MEDIA_SRC_DOMAINS`. These are read at container startup — no image rebuild needed.
- **Auth cookies:** Set `AUTH_COOKIE_DOMAIN` to your root domain (e.g. `.yourdomain.com`) when API and dashboard use different subdomains. Without it, auth cookies may not be set and login will fail.
- **Auto-generated:** `AUTH_BEARER_TOKEN`, `PRIVATE_SERVER_KEY` (by `./run-docker-full-stack.sh`).
- **Auto-configured:** All `MINIO_*` / `OBJECT_STORAGE_*` vars (by the startup script).
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
set -e
if [ "${SKIP_DB_SETUP}" = "true" ]; then
echo "Skipping database setup (SKIP_DB_SETUP=true)."
else
echo "Running database setup..."
pnpm --filter @cio/db db:setup
echo "Database setup complete."
fi
echo "Starting API..."
exec pnpm --filter @cio/api start
+4 -1
View File
@@ -41,7 +41,10 @@
"onlyBuiltDependencies": [
"esbuild",
"@tailwindcss/oxide"
]
],
"patchedDependencies": {
"[email protected]": "patches/[email protected]"
}
},
"packageManager": "[email protected]+sha512.c9fc7236e92adf5c8af42fd5bf1612df99c2ceb62f27047032f4720b33f8eacdde311865e91c411f2774f618d82f320808ecb51718bfa82c060c4ba7c76a32b8"
}
@@ -1,6 +1,6 @@
import * as schema from '@db/schema';
import { and, desc, eq, isNull } from 'drizzle-orm';
import { and, count, desc, eq, isNull } from 'drizzle-orm';
import { db } from '@db/drizzle';
import type { TNewOrganizationApiKey, TOrganizationApiKey, TOrganizationApiKeyType } from '@db/types';
@@ -42,6 +42,29 @@ export const listOrganizationApiKeys = async (
}
};
export const countActiveOrganizationApiKeys = async (
organizationId: string,
type: TOrganizationApiKeyType
): Promise<number> => {
try {
const [row] = await db
.select({ total: count() })
.from(schema.organizationApiKey)
.where(
and(
eq(schema.organizationApiKey.organizationId, organizationId),
eq(schema.organizationApiKey.type, type),
isNull(schema.organizationApiKey.revokedAt)
)
);
return Number(row?.total ?? 0);
} catch (error) {
console.error('countActiveOrganizationApiKeys error:', error);
throw new Error('Failed to count active organization API keys');
}
};
export const getOrganizationApiKeyById = async (
organizationId: string,
keyId: string
@@ -0,0 +1,124 @@
import * as schema from '@db/schema';
import { and, count, desc, eq, gte, sql } from 'drizzle-orm';
import { db } from '@db/drizzle';
import type { TAutomationUsageCategory, TNewOrganizationAutomationUsage, TOrganizationApiKeyType } from '@db/types';
export const createOrganizationAutomationUsage = async (data: TNewOrganizationAutomationUsage) => {
try {
const [row] = await db.insert(schema.organizationAutomationUsage).values(data).returning();
if (!row) {
throw new Error('Failed to create organization automation usage');
}
return row;
} catch (error) {
console.error('createOrganizationAutomationUsage error:', error);
throw new Error('Failed to create organization automation usage');
}
};
export const countOrganizationAutomationUsageSinceByKey = async (
organizationApiKeyId: string,
category: TAutomationUsageCategory,
since: string
): Promise<number> => {
try {
const [row] = await db
.select({ total: count() })
.from(schema.organizationAutomationUsage)
.where(
and(
eq(schema.organizationAutomationUsage.organizationApiKeyId, organizationApiKeyId),
eq(schema.organizationAutomationUsage.category, category),
gte(schema.organizationAutomationUsage.createdAt, since)
)
);
return Number(row?.total ?? 0);
} catch (error) {
console.error('countOrganizationAutomationUsageSinceByKey error:', error);
throw new Error('Failed to count organization automation usage by key');
}
};
export const countOrganizationAutomationUsageSince = async (
organizationId: string,
type: TOrganizationApiKeyType,
category: TAutomationUsageCategory,
since: string
): Promise<number> => {
try {
const [row] = await db
.select({ total: count() })
.from(schema.organizationAutomationUsage)
.where(
and(
eq(schema.organizationAutomationUsage.organizationId, organizationId),
eq(schema.organizationAutomationUsage.type, type),
eq(schema.organizationAutomationUsage.category, category),
gte(schema.organizationAutomationUsage.createdAt, since)
)
);
return Number(row?.total ?? 0);
} catch (error) {
console.error('countOrganizationAutomationUsageSince error:', error);
throw new Error('Failed to count organization automation usage');
}
};
export const getOrganizationAutomationCreditsUsedSince = async (
organizationId: string,
type: TOrganizationApiKeyType,
since: string
): Promise<number> => {
try {
const [row] = await db
.select({
total: sql<number>`COALESCE(SUM(${schema.organizationAutomationUsage.creditsConsumed}), 0)`
})
.from(schema.organizationAutomationUsage)
.where(
and(
eq(schema.organizationAutomationUsage.organizationId, organizationId),
eq(schema.organizationAutomationUsage.type, type),
gte(schema.organizationAutomationUsage.createdAt, since)
)
);
return Number(row?.total ?? 0);
} catch (error) {
console.error('getOrganizationAutomationCreditsUsedSince error:', error);
throw new Error('Failed to get organization automation credits used');
}
};
export const listRecentOrganizationAutomationUsage = async (
organizationId: string,
type: TOrganizationApiKeyType,
limit = 10
) => {
try {
return await db
.select({
action: schema.organizationAutomationUsage.action,
creditsConsumed: schema.organizationAutomationUsage.creditsConsumed,
createdAt: schema.organizationAutomationUsage.createdAt
})
.from(schema.organizationAutomationUsage)
.where(
and(
eq(schema.organizationAutomationUsage.organizationId, organizationId),
eq(schema.organizationAutomationUsage.type, type)
)
)
.orderBy(desc(schema.organizationAutomationUsage.createdAt))
.limit(limit);
} catch (error) {
console.error('listRecentOrganizationAutomationUsage error:', error);
throw new Error('Failed to list recent organization automation usage');
}
};

Some files were not shown because too many files have changed in this diff Show More