feat: implement automation feature
This commit is contained in:
+10
-1
@@ -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
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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')!;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
.get(
|
||||
'/',
|
||||
authOrAutomationKeyMiddleware,
|
||||
courseMemberOrAutomationKeyMiddleware(['course:exercise:read']),
|
||||
zValidator('query', ZExerciseListQuery),
|
||||
async (c) => {
|
||||
try {
|
||||
const courseId = c.req.param('courseId')!;
|
||||
const user = c.get('user')!;
|
||||
const user = c.get('user');
|
||||
const automationKey = c.get('automationKey');
|
||||
const { lessonId, sectionId } = c.req.valid('query');
|
||||
const exercises = await listExercises(courseId, { lessonId, sectionId }, user.id);
|
||||
|
||||
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) => {
|
||||
.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 exercise = await getExercise(exerciseId, undefined, user.id);
|
||||
const user = c.get('user');
|
||||
const automationKey = c.get('automationKey');
|
||||
|
||||
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) => {
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/',
|
||||
authOrAutomationKeyMiddleware,
|
||||
courseMemberOrAutomationKeyMiddleware(['course:exercise:write']),
|
||||
zValidator('json', ZExerciseCreate),
|
||||
async (c) => {
|
||||
try {
|
||||
const data = c.req.valid('json');
|
||||
const automationKey = c.get('automationKey');
|
||||
|
||||
if (automationKey?.type === 'mcp') {
|
||||
await assertMcpAutomationUsageAllowed(automationKey, 'create_course_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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,11 +72,18 @@ async function assertCourseContentItems(courseId: string, items: TCourseContentU
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateCourseContent(courseId: string, items: TCourseContentUpdate['items']) {
|
||||
try {
|
||||
await assertCourseContentItems(courseId, items);
|
||||
async function assertCourseSections(courseId: string, sections: NonNullable<TCourseContentReorder['sections']>) {
|
||||
const existingSections = await getCourseSectionsByCourseId(courseId);
|
||||
const sectionIds = new Set(existingSections.map((section) => section.id));
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
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,
|
||||
@@ -77,7 +92,11 @@ export async function updateCourseContent(courseId: string, items: TCourseConten
|
||||
.filter(({ payload }) => Object.keys(payload).length > 0);
|
||||
|
||||
if (updates.length === 0) {
|
||||
return;
|
||||
return {
|
||||
updatedItems: 0,
|
||||
updatedLessons: 0,
|
||||
updatedExercises: 0
|
||||
};
|
||||
}
|
||||
|
||||
const updatedAt = new Date().toISOString();
|
||||
@@ -180,6 +199,48 @@ export async function updateCourseContent(courseId: string, items: TCourseConten
|
||||
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) => {
|
||||
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
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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/**/*"],
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,13 +44,17 @@
|
||||
<Separator orientation="vertical" />
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<p class="max-w-2xs truncate text-sm font-medium">
|
||||
{courseApi.course?.title || ''}
|
||||
</p>
|
||||
|
||||
<span class="grow"></span>
|
||||
{#if showCoursePublishBadge}
|
||||
<CoursePublishBadge isPublished={courseApi.course?.isPublished} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Search />
|
||||
<span class="grow"></span>
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
@@ -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>
|
||||
+4
-4
@@ -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">
|
||||
|
||||
+15
-6
@@ -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)}>
|
||||
<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')}
|
||||
</Button>
|
||||
</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)}>
|
||||
<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')}
|
||||
</Button>
|
||||
</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>
|
||||
|
||||
{#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>
|
||||
|
||||
+8
-4
@@ -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}
|
||||
|
||||
+4
-2
@@ -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/katex@0.12.0/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
|
||||
};
|
||||
}
|
||||
@@ -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() {
|
||||
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 = '';
|
||||
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);
|
||||
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="you@domain.com"
|
||||
disabled={loading || isEmailPrefilled}
|
||||
readonly={isEmailPrefilled}
|
||||
@@ -173,6 +244,7 @@
|
||||
</Field.Content>
|
||||
</Field.Field>
|
||||
|
||||
{#if !ssoState.required}
|
||||
<Field.Field>
|
||||
<Field.Label for="password">{$t('login.fields.password')}</Field.Label>
|
||||
<Field.Content>
|
||||
@@ -215,6 +287,7 @@
|
||||
<Button type="submit" disabled={disableSubmit || loading} {loading} class="ui:w-full">
|
||||
{$t('login.create_account')}
|
||||
</Button>
|
||||
{/if}
|
||||
</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()}>
|
||||
<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>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if !data.personId}
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<Button class="mr-2" onclick={handleClick}>
|
||||
<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>
|
||||
<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}`);
|
||||
};
|
||||
+5
-5
@@ -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
@@ -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/katex@0.12.0/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
@@ -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.
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 pnpm@10.19.0 --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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 \
|
||||
.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -41,7 +41,10 @@
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild",
|
||||
"@tailwindcss/oxide"
|
||||
]
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"layerchart@2.0.0-next.27": "patches/layerchart@2.0.0-next.27.patch"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@10.19.0+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
Reference in New Issue
Block a user