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
|
# When set, invite emails and other links point here instead of app.classroomio.com
|
||||||
DASHBOARD_ORIGIN=http://localhost:3082
|
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.
|
# Comma-separated. Include your CDN, API, video, and font domains.
|
||||||
|
# ALLOWED_EXTERNAL_DOMAINS overrides all per-directive vars below.
|
||||||
ALLOWED_EXTERNAL_DOMAINS=
|
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)
|
# 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 { ZCourseContentDelete, ZCourseContentReorder, ZCourseContentUpdate } from '@cio/utils/validation/course';
|
||||||
import { deleteCourseContent, updateCourseContent } from '@api/services/course/content';
|
import { deleteCourseContent, reorderCourseContent, updateCourseContent } from '@api/services/course/content';
|
||||||
|
|
||||||
import { Hono } from '@api/utils/hono';
|
import { Hono } from '@api/utils/hono';
|
||||||
|
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
|
||||||
import { authMiddleware } from '@api/middlewares/auth';
|
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 { courseTeamMemberMiddleware } from '@api/middlewares/course-team-member';
|
||||||
|
import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage';
|
||||||
import { handleError } from '@api/utils/errors';
|
import { handleError } from '@api/utils/errors';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
|
||||||
export const contentRouter = new Hono()
|
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) => {
|
.put('/', authMiddleware, courseTeamMemberMiddleware, zValidator('json', ZCourseContentUpdate), async (c) => {
|
||||||
try {
|
try {
|
||||||
const courseId = c.req.param('courseId')!;
|
const courseId = c.req.param('courseId')!;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
ZCourseGetBySlugParam,
|
ZCourseGetBySlugParam,
|
||||||
ZCourseGetParam,
|
ZCourseGetParam,
|
||||||
ZCourseGetQuery,
|
ZCourseGetQuery,
|
||||||
|
ZCourseLandingPageUpdate,
|
||||||
ZCourseProgressParam,
|
ZCourseProgressParam,
|
||||||
ZCourseProgressQuery,
|
ZCourseProgressQuery,
|
||||||
ZCourseUpdate,
|
ZCourseUpdate,
|
||||||
@@ -30,9 +31,11 @@ import { getCourseTags, replaceCourseTags } from '@api/services/tag';
|
|||||||
import { Hono } from '@api/utils/hono';
|
import { Hono } from '@api/utils/hono';
|
||||||
import { attendanceRouter } from '@api/routes/course/attendance';
|
import { attendanceRouter } from '@api/routes/course/attendance';
|
||||||
import { authMiddleware } from '@api/middlewares/auth';
|
import { authMiddleware } from '@api/middlewares/auth';
|
||||||
|
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
|
||||||
import { cloneCourse } from '@api/services/course/clone';
|
import { cloneCourse } from '@api/services/course/clone';
|
||||||
import { contentRouter } from '@api/routes/course/content';
|
import { contentRouter } from '@api/routes/course/content';
|
||||||
import { courseMemberMiddleware } from '@api/middlewares/course-member';
|
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 { courseTeamMemberMiddleware } from '@api/middlewares/course-team-member';
|
||||||
import { createRateLimiter } from '@api/middlewares/rate-limiter';
|
import { createRateLimiter } from '@api/middlewares/rate-limiter';
|
||||||
import { enrollInCourse } from '@api/services/course/invite';
|
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 { orgMemberMiddleware } from '@api/middlewares/org-member';
|
||||||
import { paymentRequestRouter } from '@api/routes/course/payment-request';
|
import { paymentRequestRouter } from '@api/routes/course/payment-request';
|
||||||
import { presignRouter } from '@api/routes/course/presign';
|
import { presignRouter } from '@api/routes/course/presign';
|
||||||
|
import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage';
|
||||||
import { sectionRouter } from '@api/routes/course/section';
|
import { sectionRouter } from '@api/routes/course/section';
|
||||||
import { submissionRouter } from '@api/routes/course/submission';
|
import { submissionRouter } from '@api/routes/course/submission';
|
||||||
|
import { updateCourseLandingPageService } from '@api/services/course/landing-page';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
|
||||||
const enrollRateLimit = createRateLimiter({
|
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
|
* PUT /course/:courseId
|
||||||
* Updates a course
|
* Updates a course
|
||||||
|
|||||||
@@ -21,24 +21,43 @@ import { getGroupMemberIdByCourseAndProfile, isCourseTeamMemberOrOrgAdmin } from
|
|||||||
|
|
||||||
import { Hono } from '@api/utils/hono';
|
import { Hono } from '@api/utils/hono';
|
||||||
import { authMiddleware } from '@api/middlewares/auth';
|
import { authMiddleware } from '@api/middlewares/auth';
|
||||||
|
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
|
||||||
import { courseMemberMiddleware } from '@api/middlewares/course-member';
|
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 { createSubmissionService, listExerciseSubmissionsOverview } from '@api/services/submission';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
|
||||||
export const exerciseRouter = new Hono()
|
export const exerciseRouter = new Hono()
|
||||||
// Exercise CRUD routes
|
// Exercise CRUD routes
|
||||||
.get('/', authMiddleware, courseMemberMiddleware, zValidator('query', ZExerciseListQuery), async (c) => {
|
.get(
|
||||||
|
'/',
|
||||||
|
authOrAutomationKeyMiddleware,
|
||||||
|
courseMemberOrAutomationKeyMiddleware(['course:exercise:read']),
|
||||||
|
zValidator('query', ZExerciseListQuery),
|
||||||
|
async (c) => {
|
||||||
try {
|
try {
|
||||||
const courseId = c.req.param('courseId')!;
|
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 { 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);
|
return c.json({ success: true, data: exercises }, 200);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleError(c, error, 'Failed to list exercises');
|
return handleError(c, error, 'Failed to list exercises');
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
)
|
||||||
.get('/:exerciseId/submissions', authMiddleware, courseMemberMiddleware, async (c) => {
|
.get('/:exerciseId/submissions', authMiddleware, courseMemberMiddleware, async (c) => {
|
||||||
try {
|
try {
|
||||||
const courseId = c.req.param('courseId')!;
|
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');
|
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 {
|
try {
|
||||||
const { exerciseId } = c.req.valid('param');
|
const { exerciseId } = c.req.valid('param');
|
||||||
const user = c.get('user')!;
|
const user = c.get('user');
|
||||||
const exercise = await getExercise(exerciseId, undefined, user.id);
|
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);
|
return c.json({ success: true, data: exercise }, 200);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleError(c, error, 'Failed to fetch exercise');
|
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 {
|
try {
|
||||||
const data = c.req.valid('json');
|
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);
|
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);
|
return c.json({ success: true, data: exercise }, 201);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleError(c, error, 'Failed to create exercise');
|
return handleError(c, error, 'Failed to create exercise');
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
)
|
||||||
/**
|
/**
|
||||||
* POST /course/:courseId/exercise/from-template
|
* POST /course/:courseId/exercise/from-template
|
||||||
* Creates an exercise from a template
|
* Creates an exercise from a template
|
||||||
@@ -80,14 +136,19 @@ export const exerciseRouter = new Hono()
|
|||||||
*/
|
*/
|
||||||
.post(
|
.post(
|
||||||
'/from-template',
|
'/from-template',
|
||||||
authMiddleware,
|
authOrAutomationKeyMiddleware,
|
||||||
courseMemberMiddleware,
|
courseMemberOrAutomationKeyMiddleware(['course:exercise:write']),
|
||||||
zValidator('json', ZExerciseFromTemplate),
|
zValidator('json', ZExerciseFromTemplate),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
try {
|
try {
|
||||||
const courseId = c.req.param('courseId')!;
|
const courseId = c.req.param('courseId')!;
|
||||||
|
const automationKey = c.get('automationKey');
|
||||||
const { lessonId, sectionId, order, templateId } = c.req.valid('json');
|
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
|
// Fetch template from database
|
||||||
const template = await fetchTemplateById(templateId);
|
const template = await fetchTemplateById(templateId);
|
||||||
if (!template) {
|
if (!template) {
|
||||||
@@ -100,6 +161,14 @@ export const exerciseRouter = new Hono()
|
|||||||
|
|
||||||
const exercise = await createExerciseFromTemplate(courseId, lessonId, sectionId, order, template);
|
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);
|
return c.json({ success: true, data: exercise }, 201);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleError(c, error, 'Failed to create exercise from template');
|
return handleError(c, error, 'Failed to create exercise from template');
|
||||||
@@ -108,8 +177,8 @@ export const exerciseRouter = new Hono()
|
|||||||
)
|
)
|
||||||
.put(
|
.put(
|
||||||
'/:exerciseId',
|
'/:exerciseId',
|
||||||
authMiddleware,
|
authOrAutomationKeyMiddleware,
|
||||||
courseMemberMiddleware,
|
courseMemberOrAutomationKeyMiddleware(['course:exercise:write']),
|
||||||
zValidator('param', ZExerciseGetParam),
|
zValidator('param', ZExerciseGetParam),
|
||||||
zValidator('json', ZExerciseUpdate),
|
zValidator('json', ZExerciseUpdate),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
@@ -117,9 +186,14 @@ export const exerciseRouter = new Hono()
|
|||||||
const { exerciseId } = c.req.valid('param');
|
const { exerciseId } = c.req.valid('param');
|
||||||
const data = c.req.valid('json');
|
const data = c.req.valid('json');
|
||||||
const courseId = c.req.param('courseId')!;
|
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);
|
const isAuthorized = await isCourseTeamMemberOrOrgAdmin(courseId, user.id);
|
||||||
|
|
||||||
if (!isAuthorized) {
|
if (!isAuthorized) {
|
||||||
@@ -136,6 +210,10 @@ export const exerciseRouter = new Hono()
|
|||||||
|
|
||||||
const exercise = await updateExerciseService(exerciseId, data);
|
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);
|
return c.json({ success: true, data: exercise }, 200);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleError(c, error, 'Failed to update exercise');
|
return handleError(c, error, 'Failed to update exercise');
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ZCreateOrganizationApiKey,
|
ZCreateOrganizationApiKey,
|
||||||
|
ZOrganizationAutomationUsageQuery,
|
||||||
ZListOrganizationApiKeysQuery,
|
ZListOrganizationApiKeysQuery,
|
||||||
ZOrganizationApiKeyParam
|
ZOrganizationApiKeyParam
|
||||||
} from '@cio/utils/validation/organization';
|
} from '@cio/utils/validation/organization';
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
revokeOrganizationApiKeyService,
|
revokeOrganizationApiKeyService,
|
||||||
rotateOrganizationApiKeyService
|
rotateOrganizationApiKeyService
|
||||||
} from '@api/services/organization/automation-key';
|
} from '@api/services/organization/automation-key';
|
||||||
|
import { getOrganizationAutomationUsageSummaryService } from '@api/services/organization/automation-usage';
|
||||||
|
|
||||||
import { Hono } from '@api/utils/hono';
|
import { Hono } from '@api/utils/hono';
|
||||||
import { authMiddleware } from '@api/middlewares/auth';
|
import { authMiddleware } from '@api/middlewares/auth';
|
||||||
@@ -17,6 +19,23 @@ import { orgAdminMiddleware } from '@api/middlewares/org-admin';
|
|||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
|
||||||
export const automationRouter = new Hono()
|
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) => {
|
.get('/keys', authMiddleware, orgAdminMiddleware, zValidator('query', ZListOrganizationApiKeysQuery), async (c) => {
|
||||||
try {
|
try {
|
||||||
const orgId = c.req.header('cio-org-id')!;
|
const orgId = c.req.header('cio-org-id')!;
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
|
import { ZAutomationDraftTagAssignment, ZAutomationDraftTagParam } from '@cio/utils/validation/tag';
|
||||||
import {
|
import {
|
||||||
|
ZCourseImportCourseParam,
|
||||||
ZCourseImportDraftCreate,
|
ZCourseImportDraftCreate,
|
||||||
|
ZCourseImportDraftCreateFromCourse,
|
||||||
ZCourseImportDraftGetParam,
|
ZCourseImportDraftGetParam,
|
||||||
ZCourseImportDraftPublish,
|
ZCourseImportDraftPublish,
|
||||||
|
ZCourseImportDraftPublishToCourse,
|
||||||
ZCourseImportDraftUpdate
|
ZCourseImportDraftUpdate
|
||||||
} from '@cio/utils/validation/course-import';
|
} from '@cio/utils/validation/course-import';
|
||||||
import {
|
import {
|
||||||
|
createCourseImportDraftFromCourseService,
|
||||||
createCourseImportDraftService,
|
createCourseImportDraftService,
|
||||||
getCourseImportDraftService,
|
getCourseImportDraftService,
|
||||||
|
getCourseImportStructureService,
|
||||||
publishCourseImportDraftService,
|
publishCourseImportDraftService,
|
||||||
|
publishCourseImportDraftToExistingCourseService,
|
||||||
|
updateCourseImportDraftTagsService,
|
||||||
updateCourseImportDraftService
|
updateCourseImportDraftService
|
||||||
} from '@api/services/course-import/course-import';
|
} from '@api/services/course-import/course-import';
|
||||||
|
import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage';
|
||||||
|
|
||||||
import { Hono } from '@api/utils/hono';
|
import { Hono } from '@api/utils/hono';
|
||||||
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
|
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';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
|
||||||
export const courseImportRouter = new Hono()
|
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(
|
.post(
|
||||||
'/drafts',
|
'/drafts',
|
||||||
authOrAutomationKeyMiddleware,
|
authOrAutomationKeyMiddleware,
|
||||||
@@ -27,16 +63,56 @@ export const courseImportRouter = new Hono()
|
|||||||
try {
|
try {
|
||||||
const orgId = c.get('orgId')!;
|
const orgId = c.get('orgId')!;
|
||||||
const actorId = c.get('actorId')!;
|
const actorId = c.get('actorId')!;
|
||||||
|
const automationKey = c.get('automationKey');
|
||||||
const payload = c.req.valid('json');
|
const payload = c.req.valid('json');
|
||||||
|
|
||||||
|
if (automationKey?.type === 'mcp') {
|
||||||
|
await assertMcpAutomationUsageAllowed(automationKey, 'create_course_draft');
|
||||||
|
}
|
||||||
|
|
||||||
const draft = await createCourseImportDraftService(orgId, actorId, payload);
|
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);
|
return c.json({ success: true, data: draft }, 201);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleError(c, error, 'Failed to create course import draft');
|
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(
|
.get(
|
||||||
'/drafts/:draftId',
|
'/drafts/:draftId',
|
||||||
authOrAutomationKeyMiddleware,
|
authOrAutomationKeyMiddleware,
|
||||||
@@ -45,16 +121,54 @@ export const courseImportRouter = new Hono()
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
try {
|
try {
|
||||||
const orgId = c.get('orgId')!;
|
const orgId = c.get('orgId')!;
|
||||||
|
const automationKey = c.get('automationKey');
|
||||||
const { draftId } = c.req.valid('param');
|
const { draftId } = c.req.valid('param');
|
||||||
|
|
||||||
|
if (automationKey?.type === 'mcp') {
|
||||||
|
await assertMcpAutomationUsageAllowed(automationKey, 'get_course_draft');
|
||||||
|
}
|
||||||
|
|
||||||
const draft = await getCourseImportDraftService(orgId, draftId);
|
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);
|
return c.json({ success: true, data: draft }, 200);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleError(c, error, 'Failed to fetch course import draft');
|
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(
|
.put(
|
||||||
'/drafts/:draftId',
|
'/drafts/:draftId',
|
||||||
authOrAutomationKeyMiddleware,
|
authOrAutomationKeyMiddleware,
|
||||||
@@ -64,11 +178,20 @@ export const courseImportRouter = new Hono()
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
try {
|
try {
|
||||||
const orgId = c.get('orgId')!;
|
const orgId = c.get('orgId')!;
|
||||||
|
const automationKey = c.get('automationKey');
|
||||||
const { draftId } = c.req.valid('param');
|
const { draftId } = c.req.valid('param');
|
||||||
const payload = c.req.valid('json');
|
const payload = c.req.valid('json');
|
||||||
|
|
||||||
|
if (automationKey?.type === 'mcp') {
|
||||||
|
await assertMcpAutomationUsageAllowed(automationKey, 'update_course_draft');
|
||||||
|
}
|
||||||
|
|
||||||
const draft = await updateCourseImportDraftService(orgId, draftId, payload);
|
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);
|
return c.json({ success: true, data: draft }, 200);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleError(c, error, 'Failed to update course import draft');
|
return handleError(c, error, 'Failed to update course import draft');
|
||||||
@@ -85,14 +208,58 @@ export const courseImportRouter = new Hono()
|
|||||||
try {
|
try {
|
||||||
const orgId = c.get('orgId')!;
|
const orgId = c.get('orgId')!;
|
||||||
const actorId = c.get('actorId')!;
|
const actorId = c.get('actorId')!;
|
||||||
|
const automationKey = c.get('automationKey');
|
||||||
const { draftId } = c.req.valid('param');
|
const { draftId } = c.req.valid('param');
|
||||||
const payload = c.req.valid('json');
|
const payload = c.req.valid('json');
|
||||||
|
|
||||||
|
if (automationKey?.type === 'mcp') {
|
||||||
|
await assertMcpAutomationUsageAllowed(automationKey, 'publish_course_draft');
|
||||||
|
}
|
||||||
|
|
||||||
const result = await publishCourseImportDraftService(orgId, actorId, draftId, payload);
|
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);
|
return c.json({ success: true, data: result }, 201);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleError(c, error, 'Failed to publish course import draft');
|
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 { automationRouter } from '@api/routes/organization/automation';
|
||||||
import { assetsRouter } from '@api/routes/organization/assets';
|
import { assetsRouter } from '@api/routes/organization/assets';
|
||||||
import { authMiddleware } from '@api/middlewares/auth';
|
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 { 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 { courseImportRouter } from '@api/routes/organization/course-import';
|
||||||
import { getLMSExercisesService } from '@api/services/exercise';
|
import { getLMSExercisesService } from '@api/services/exercise';
|
||||||
import { handleError } from '@api/utils/errors';
|
import { handleError } from '@api/utils/errors';
|
||||||
import { inviteTeamMembers } from '@api/services/organization/invite';
|
import { inviteTeamMembers } from '@api/services/organization/invite';
|
||||||
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
|
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
|
||||||
import { orgMemberMiddleware } from '@api/middlewares/org-member';
|
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 { quizRouter } from '@api/routes/organization/quiz';
|
||||||
import { tagsRouter } from '@api/routes/organization/tags';
|
import { tagsRouter } from '@api/routes/organization/tags';
|
||||||
|
import { ROLE } from '@cio/utils/constants';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
|
||||||
export const organizationRouter = new Hono()
|
export const organizationRouter = new Hono()
|
||||||
@@ -287,17 +291,18 @@ export const organizationRouter = new Hono()
|
|||||||
*/
|
*/
|
||||||
.get(
|
.get(
|
||||||
'/courses',
|
'/courses',
|
||||||
authMiddleware,
|
authOrAutomationKeyMiddleware,
|
||||||
orgMemberMiddleware,
|
orgMemberOrAutomationKeyMiddleware(['course:read']),
|
||||||
zValidator('query', ZGetOrganizationCoursesQuery),
|
zValidator('query', ZGetOrganizationCoursesQuery),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
try {
|
try {
|
||||||
const user = c.get('user')!;
|
const user = c.get('user');
|
||||||
const orgId = c.get('orgId');
|
const orgId = c.get('orgId');
|
||||||
const userRole = c.get('userRole');
|
const userRole = c.get('userRole');
|
||||||
|
const automationKey = c.get('automationKey');
|
||||||
const { tags } = c.req.valid('query');
|
const { tags } = c.req.valid('query');
|
||||||
|
|
||||||
if (!orgId || userRole === null) {
|
if (!orgId) {
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
@@ -308,22 +313,25 @@ export const organizationRouter = new Hono()
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (automationKey?.type === 'mcp') {
|
||||||
|
await assertMcpAutomationUsageAllowed(automationKey, 'list_org_courses');
|
||||||
|
}
|
||||||
|
|
||||||
const tagSlugs = tags
|
const tagSlugs = tags
|
||||||
?.split(',')
|
?.split(',')
|
||||||
.map((value) => value.trim())
|
.map((value) => value.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
console.log('user', user);
|
|
||||||
console.log('userRole', userRole);
|
|
||||||
console.log('orgId', orgId);
|
|
||||||
console.log('tagSlugs', tagSlugs);
|
|
||||||
const result = await getOrganizationCourses(
|
const result = await getOrganizationCourses(
|
||||||
orgId,
|
orgId,
|
||||||
user.id,
|
user?.id ?? automationKey?.createdByProfileId ?? '',
|
||||||
userRole,
|
userRole ?? ROLE.ADMIN,
|
||||||
tagSlugs && tagSlugs.length > 0 ? tagSlugs : undefined
|
tagSlugs && tagSlugs.length > 0 ? tagSlugs : undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (automationKey?.type === 'mcp') {
|
||||||
|
await recordMcpAutomationUsage(automationKey, 'list_org_courses', { tags: tagSlugs });
|
||||||
|
}
|
||||||
|
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
ZAutomationCourseTagAssignment,
|
||||||
ZTagCreate,
|
ZTagCreate,
|
||||||
ZTagGroupCreate,
|
ZTagGroupCreate,
|
||||||
ZTagGroupParam,
|
ZTagGroupParam,
|
||||||
@@ -7,6 +8,7 @@ import {
|
|||||||
ZTagUpdate
|
ZTagUpdate
|
||||||
} from '@cio/utils/validation/tag';
|
} from '@cio/utils/validation/tag';
|
||||||
import {
|
import {
|
||||||
|
assignTagsToCoursesByName,
|
||||||
createOrganizationTag,
|
createOrganizationTag,
|
||||||
createOrganizationTagGroup,
|
createOrganizationTagGroup,
|
||||||
getPublicOrganizationTagGroups,
|
getPublicOrganizationTagGroups,
|
||||||
@@ -18,8 +20,11 @@ import { ZGetCoursesBySiteName } from '@cio/utils/validation/organization';
|
|||||||
|
|
||||||
import { Hono } from '@api/utils/hono';
|
import { Hono } from '@api/utils/hono';
|
||||||
import { authMiddleware } from '@api/middlewares/auth';
|
import { authMiddleware } from '@api/middlewares/auth';
|
||||||
|
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
|
||||||
import { handleError } from '@api/utils/errors';
|
import { handleError } from '@api/utils/errors';
|
||||||
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
|
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';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
|
||||||
export const tagsRouter = new Hono()
|
export const tagsRouter = new Hono()
|
||||||
@@ -72,6 +77,39 @@ export const tagsRouter = new Hono()
|
|||||||
return handleError(c, error, 'Failed to create tag group');
|
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(
|
.put(
|
||||||
'/groups/:groupId',
|
'/groups/:groupId',
|
||||||
authMiddleware,
|
authMiddleware,
|
||||||
|
|||||||
@@ -1,22 +1,37 @@
|
|||||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
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 {
|
import {
|
||||||
createCourseImportDraft,
|
createCourseImportDraft,
|
||||||
getCourseImportDraftById,
|
getCourseImportDraftById,
|
||||||
getCourseImportDraftByIdempotencyKey,
|
getCourseImportDraftByIdempotencyKey,
|
||||||
updateCourseImportDraft
|
updateCourseImportDraft
|
||||||
} from '@cio/db/queries/course-import';
|
} from '@cio/db/queries/course-import';
|
||||||
import type { TCourseImportDraft, TLocale } from '@db/types';
|
import { getCourseById } from '@cio/db/queries/course';
|
||||||
import type {
|
import { getLessonLanguagesByLessonIds } from '@cio/db/queries/lesson';
|
||||||
TCourseImportDraftCreate,
|
import { getOrganizationById } from '@cio/db/queries/organization';
|
||||||
TCourseImportDraftPayload,
|
import { getCourseTagsForOrganization } from '@cio/db/queries/tag';
|
||||||
TCourseImportDraftPublish,
|
import type { TCourse, TCourseImportDraft, TLessonLanguage, TLocale, TOrganization } from '@db/types';
|
||||||
TCourseImportDraftUpdate
|
import {
|
||||||
|
type TCourseImportDraftCreate,
|
||||||
|
type TCourseImportDraftCreateFromCourse,
|
||||||
|
type TCourseImportDraftPayload,
|
||||||
|
type TCourseImportDraftPublish,
|
||||||
|
type TCourseImportDraftPublishToCourse,
|
||||||
|
type TCourseImportDraftUpdate,
|
||||||
|
ZCourseImportDraftPayload,
|
||||||
|
ZCourseImportWarning
|
||||||
} from '@cio/utils/validation/course-import';
|
} from '@cio/utils/validation/course-import';
|
||||||
import { ZCourseImportDraftPayload } from '@cio/utils/validation/course-import';
|
import { getCourseOrganizationId } from '@cio/db/queries/tag';
|
||||||
import { createCourse, updateCourse } from '@api/services/course/course';
|
import { assignCourseTagsByName } from '@api/services/tag';
|
||||||
import { createCourseSection } from '@api/services/course/section';
|
import { db } from '@cio/db/drizzle';
|
||||||
import { createLesson } from '@api/services/lesson';
|
import * as schema from '@cio/db/schema';
|
||||||
import { upsertLessonLanguageService } from '@api/services/lesson-language';
|
import { generateSlug } from '@cio/utils/functions';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
type DraftSummary = {
|
type DraftSummary = {
|
||||||
sectionCount: number;
|
sectionCount: number;
|
||||||
@@ -25,6 +40,30 @@ type DraftSummary = {
|
|||||||
warningCount: number;
|
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 {
|
function buildDraftSummary(draft: TCourseImportDraftPayload): DraftSummary {
|
||||||
return {
|
return {
|
||||||
sectionCount: draft.sections.length,
|
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 {
|
function parseStoredDraft(record: TCourseImportDraft): TCourseImportDraftPayload {
|
||||||
return ZCourseImportDraftPayload.parse(record.draft);
|
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) {
|
export async function createCourseImportDraftService(orgId: string, profileId: string, data: TCourseImportDraftCreate) {
|
||||||
try {
|
try {
|
||||||
if (data.idempotencyKey) {
|
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) {
|
export async function getCourseImportDraftService(orgId: string, draftId: string) {
|
||||||
try {
|
try {
|
||||||
const draft = await getCourseImportDraftById(orgId, draftId);
|
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(
|
export async function publishCourseImportDraftService(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
profileId: string,
|
profileId: string,
|
||||||
draftId: string,
|
draftId: string,
|
||||||
overrides: TCourseImportDraftPublish
|
overrides: TCourseImportDraftPublish
|
||||||
) {
|
): Promise<PublishDraftResult> {
|
||||||
try {
|
try {
|
||||||
const existing = await getCourseImportDraftService(orgId, draftId);
|
const existing = await getCourseImportDraftService(orgId, draftId);
|
||||||
|
|
||||||
if (existing.status === 'PUBLISHED' && existing.publishedCourseId) {
|
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 {
|
return {
|
||||||
courseId: existing.publishedCourseId,
|
courseId: existing.publishedCourseId,
|
||||||
|
courseUrl,
|
||||||
|
bannerImageUrl: publishedCourse?.logo ?? null,
|
||||||
|
tagNames: normalizeDraftTagNames(draft.tags),
|
||||||
createdSections: 0,
|
createdSections: 0,
|
||||||
createdLessons: 0,
|
createdLessons: 0,
|
||||||
localeCount: 0
|
localeCount: 0
|
||||||
@@ -182,6 +601,9 @@ export async function publishCourseImportDraftService(
|
|||||||
await updateCourse(courseId, { metadata: courseMetadata });
|
await updateCourse(courseId, { metadata: courseMetadata });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bannerImageUrl = await resolvePublishBannerImage(courseTitle, overrides);
|
||||||
|
await maybeApplyCourseBannerImage(courseId, bannerImageUrl);
|
||||||
|
|
||||||
const sectionIdMap = new Map<string, string>();
|
const sectionIdMap = new Map<string, string>();
|
||||||
for (const section of [...draft.sections].sort((a, b) => a.order - b.order)) {
|
for (const section of [...draft.sections].sort((a, b) => a.order - b.order)) {
|
||||||
const createdSection = await createCourseSection(courseId, {
|
const createdSection = await createCourseSection(courseId, {
|
||||||
@@ -203,6 +625,7 @@ export async function publishCourseImportDraftService(
|
|||||||
const createdLesson = await createLesson(courseId, {
|
const createdLesson = await createLesson(courseId, {
|
||||||
courseId,
|
courseId,
|
||||||
title: lesson.title,
|
title: lesson.title,
|
||||||
|
note: getPrimaryLessonContent(lesson.externalId, draft.lessonLanguages, draft.course.locale as TLocale),
|
||||||
sectionId,
|
sectionId,
|
||||||
order: lesson.order,
|
order: lesson.order,
|
||||||
isUnlocked: lesson.isUnlocked,
|
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, {
|
await updateCourseImportDraft(orgId, draftId, {
|
||||||
status: 'PUBLISHED',
|
status: 'PUBLISHED',
|
||||||
publishedCourseId: courseId,
|
publishedCourseId: courseId,
|
||||||
@@ -236,6 +669,9 @@ export async function publishCourseImportDraftService(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
courseId,
|
courseId,
|
||||||
|
courseUrl,
|
||||||
|
bannerImageUrl,
|
||||||
|
tagNames: appliedTagNames,
|
||||||
createdSections: sectionIdMap.size,
|
createdSections: sectionIdMap.size,
|
||||||
createdLessons: lessonIdMap.size,
|
createdLessons: lessonIdMap.size,
|
||||||
localeCount: new Set(draft.lessonLanguages.map((item) => item.locale)).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 { AppError, ErrorCodes } from '@api/utils/errors';
|
||||||
import { ContentType } from '@cio/utils/constants';
|
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 { 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 { getCourseContentItems, type CourseContentItemRow } from '@cio/db/queries/course/content';
|
||||||
import { deleteLesson } from '@cio/db/queries/lesson';
|
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 { 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]) {
|
function buildUpdatePayload(item: TCourseContentUpdate['items'][number]) {
|
||||||
const payload: Record<string, unknown> = {};
|
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']) {
|
async function assertCourseSections(courseId: string, sections: NonNullable<TCourseContentReorder['sections']>) {
|
||||||
try {
|
const existingSections = await getCourseSectionsByCourseId(courseId);
|
||||||
await assertCourseContentItems(courseId, items);
|
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
|
const updates = items
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
item,
|
item,
|
||||||
@@ -77,7 +92,11 @@ export async function updateCourseContent(courseId: string, items: TCourseConten
|
|||||||
.filter(({ payload }) => Object.keys(payload).length > 0);
|
.filter(({ payload }) => Object.keys(payload).length > 0);
|
||||||
|
|
||||||
if (updates.length === 0) {
|
if (updates.length === 0) {
|
||||||
return;
|
return {
|
||||||
|
updatedItems: 0,
|
||||||
|
updatedLessons: 0,
|
||||||
|
updatedExercises: 0
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedAt = new Date().toISOString();
|
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);
|
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) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) {
|
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) {
|
export async function deleteCourseContent(courseId: string, payload: TCourseContentDelete) {
|
||||||
try {
|
try {
|
||||||
if (payload.sectionId) {
|
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,
|
listOrganizationApiKeys,
|
||||||
updateOrganizationApiKey
|
updateOrganizationApiKey
|
||||||
} from '@cio/db/queries/organization';
|
} from '@cio/db/queries/organization';
|
||||||
|
import { assertOrganizationAutomationKeyCreationAllowed } from './automation-usage';
|
||||||
|
|
||||||
const DEFAULT_SCOPES: Record<TOrganizationApiKeyType, TOrganizationApiKeyScope[]> = {
|
const DEFAULT_SCOPES: Record<TOrganizationApiKeyType, TOrganizationApiKeyScope[]> = {
|
||||||
mcp: [
|
mcp: [
|
||||||
'course_import:draft:create',
|
'course_import:draft:create',
|
||||||
'course_import:draft:read',
|
'course_import:draft:read',
|
||||||
'course_import:draft:update',
|
'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:*'],
|
api: ['public_api:*'],
|
||||||
zapier: ['public_api:*']
|
zapier: ['public_api:*']
|
||||||
@@ -86,6 +92,8 @@ export async function createOrganizationApiKeyService(
|
|||||||
createdByProfileId: string,
|
createdByProfileId: string,
|
||||||
payload: TCreateOrganizationApiKey
|
payload: TCreateOrganizationApiKey
|
||||||
): Promise<{ key: OrganizationApiKeySummary; secret: string }> {
|
): Promise<{ key: OrganizationApiKeySummary; secret: string }> {
|
||||||
|
await assertOrganizationAutomationKeyCreationAllowed(organizationId, payload.type);
|
||||||
|
|
||||||
const secret = generateOrganizationApiKeySecret(payload.type);
|
const secret = generateOrganizationApiKeySecret(payload.type);
|
||||||
const key = await createOrganizationApiKey({
|
const key = await createOrganizationApiKey({
|
||||||
organizationId,
|
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 {
|
import type {
|
||||||
|
TAutomationCourseTagAssignment,
|
||||||
|
TAutomationDraftTagAssignment,
|
||||||
TCourseTagAssignment,
|
TCourseTagAssignment,
|
||||||
TTagCreate,
|
TTagCreate,
|
||||||
TTagGroupCreate,
|
TTagGroupCreate,
|
||||||
@@ -8,6 +10,8 @@ import type {
|
|||||||
import {
|
import {
|
||||||
createTag,
|
createTag,
|
||||||
createTagGroup,
|
createTagGroup,
|
||||||
|
findTagBySlug,
|
||||||
|
findTagGroupBySlug,
|
||||||
generateUniqueTagGroupSlug,
|
generateUniqueTagGroupSlug,
|
||||||
generateUniqueTagSlug,
|
generateUniqueTagSlug,
|
||||||
getCourseOrganizationId,
|
getCourseOrganizationId,
|
||||||
@@ -21,6 +25,8 @@ import {
|
|||||||
updateTagGroup
|
updateTagGroup
|
||||||
} from '@cio/db/queries/tag';
|
} from '@cio/db/queries/tag';
|
||||||
import { getOrgIdBySiteName, isUserOrgAdmin } from '@cio/db/queries/organization';
|
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';
|
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;
|
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) {
|
export async function getOrganizationTagGroups(orgId: string) {
|
||||||
try {
|
try {
|
||||||
return getTagGroupsWithTags(orgId);
|
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_FETCH_FAILED: 'ORGANIZATION_API_KEY_FETCH_FAILED',
|
||||||
ORGANIZATION_API_KEY_REVOKE_FAILED: 'ORGANIZATION_API_KEY_REVOKE_FAILED',
|
ORGANIZATION_API_KEY_REVOKE_FAILED: 'ORGANIZATION_API_KEY_REVOKE_FAILED',
|
||||||
ORGANIZATION_API_KEY_ROTATE_FAILED: 'ORGANIZATION_API_KEY_ROTATE_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_GROUP_NOT_FOUND: 'TAG_GROUP_NOT_FOUND',
|
||||||
TAG_NOT_FOUND: 'TAG_NOT_FOUND',
|
TAG_NOT_FOUND: 'TAG_NOT_FOUND',
|
||||||
TAG_FETCH_FAILED: 'TAG_FETCH_FAILED',
|
TAG_FETCH_FAILED: 'TAG_FETCH_FAILED',
|
||||||
|
|||||||
@@ -18,14 +18,14 @@
|
|||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@api/*": ["src/*"],
|
"@api/*": ["src/*"],
|
||||||
"@db/types": ["./node_modules/@cio/db/dist/types.d.ts"],
|
"@db/types": ["../../packages/db/src/types.ts"],
|
||||||
"@db/*": ["./node_modules/@cio/db/dist/*"],
|
"@db/*": ["../../packages/db/src/*"],
|
||||||
"@cio/db/*": ["./node_modules/@cio/db/dist/*"],
|
"@cio/db/*": ["../../packages/db/src/*"],
|
||||||
"@cio/db/types": ["./node_modules/@cio/db/dist/types.d.ts"],
|
"@cio/db/types": ["../../packages/db/src/types.ts"],
|
||||||
"@cio/utils": ["./node_modules/@cio/utils/dist/index.ts"],
|
"@cio/utils": ["../../packages/utils/src/index.ts"],
|
||||||
"@cio/utils/*": ["./node_modules/@cio/utils/dist/*"],
|
"@cio/utils/*": ["../../packages/utils/src/*"],
|
||||||
"@cio/email/*": ["./node_modules/@cio/email/dist/*"],
|
"@cio/email/*": ["../../packages/email/src/*"],
|
||||||
"@cio/email": ["./node_modules/@cio/email/dist/index.d.ts"]
|
"@cio/email": ["../../packages/email/src/index.ts"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
|
|||||||
@@ -6,12 +6,19 @@ PUBLIC_IS_SELFHOSTED=false
|
|||||||
# PUBLIC_APP_DESCRIPTION=A flexible LMS for your organization
|
# PUBLIC_APP_DESCRIPTION=A flexible LMS for your organization
|
||||||
# PUBLIC_OG_IMAGE_URL=https://app.yourdomain.com/og-image.png
|
# 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.
|
# CSP: External domains allowed by Content-Security-Policy.
|
||||||
# When PUBLIC_IS_SELFHOSTED=true: REQUIRED - no defaults, only these env vars are used.
|
# These are runtime env vars — set them when running the container (no rebuild needed).
|
||||||
# When PUBLIC_IS_SELFHOSTED=false: Optional - unset uses SaaS defaults.
|
# 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
|
# 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=
|
# 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 setup - Optional if you want to use Unsplash images
|
||||||
UNSPLASH_API_KEY=
|
UNSPLASH_API_KEY=
|
||||||
|
|||||||
+99
-14
@@ -46,22 +46,111 @@ html .dark {
|
|||||||
border: 2px solid var(--scrollbarBG);
|
border: 2px solid var(--scrollbarBG);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prose {
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
.prose h1,
|
.prose h1,
|
||||||
.prose h2,
|
.prose h2,
|
||||||
.prose h3,
|
.prose h3,
|
||||||
.prose h4,
|
.prose h4 {
|
||||||
.prose h5,
|
@apply scroll-m-20 tracking-tight text-balance;
|
||||||
.prose h6 {
|
color: var(--foreground);
|
||||||
font-size: revert !important;
|
}
|
||||||
font-weight: revert !important;
|
|
||||||
margin-top: 0px !important;
|
.prose h1 {
|
||||||
margin-bottom: 16px !important;
|
@apply mt-0 text-4xl font-extrabold;
|
||||||
line-height: 1.25 !important;
|
}
|
||||||
|
|
||||||
|
.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 {
|
.prose p {
|
||||||
margin-top: 0px !important;
|
@apply my-0;
|
||||||
margin-bottom: 10px !important;
|
}
|
||||||
|
|
||||||
|
.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,
|
/* .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) {
|
:not([data-slot='button']) > svg.lucide-icon:not(.custom) {
|
||||||
color: #666666;
|
color: #666666;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getSessionData } from '$lib/utils/services/auth/session';
|
import { getSessionData } from '$lib/utils/services/auth/session';
|
||||||
import { getCioCookieString } from '$lib/utils/functions/cookies';
|
import { getCioCookieString } from '$lib/utils/functions/cookies';
|
||||||
|
import { applyCspExtensions } from '$lib/utils/csp';
|
||||||
import { type Handle, redirect } from '@sveltejs/kit';
|
import { type Handle, redirect } from '@sveltejs/kit';
|
||||||
import { isPublicApiRoute, isPublicRoute } from '$lib/utils/functions/routes/isPublicRoute';
|
import { isPublicApiRoute, isPublicRoute } from '$lib/utils/functions/routes/isPublicRoute';
|
||||||
import { ROUTE } from '$lib/utils/constants/routes';
|
import { ROUTE } from '$lib/utils/constants/routes';
|
||||||
@@ -12,13 +13,15 @@ export const handle: Handle = async (args) => {
|
|||||||
event.locals = sessionData;
|
event.locals = sessionData;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle API routes
|
let response: Response;
|
||||||
|
|
||||||
if (event.url.pathname.includes('/api')) {
|
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 applyCspExtensions(response);
|
||||||
return handlePagesRoutes(args);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePagesRoutes: Handle = async ({ event, resolve }) => {
|
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 {
|
import type {
|
||||||
AutomationKey,
|
AutomationKey,
|
||||||
AutomationKeyType,
|
AutomationKeyType,
|
||||||
|
AutomationUsage,
|
||||||
CreateAutomationKeyRequest,
|
CreateAutomationKeyRequest,
|
||||||
|
GetAutomationUsageRequest,
|
||||||
ListAutomationKeysRequest,
|
ListAutomationKeysRequest,
|
||||||
RevokeAutomationKeyRequest,
|
RevokeAutomationKeyRequest,
|
||||||
RotateAutomationKeyRequest
|
RotateAutomationKeyRequest
|
||||||
@@ -15,6 +17,7 @@ class AutomationApi extends BaseApiWithErrors {
|
|||||||
generatedSecret = $state<string | null>(null);
|
generatedSecret = $state<string | null>(null);
|
||||||
isFetched = $state(false);
|
isFetched = $state(false);
|
||||||
keys = $state<AutomationKey[]>([]);
|
keys = $state<AutomationKey[]>([]);
|
||||||
|
usage = $state<AutomationUsage | null>(null);
|
||||||
|
|
||||||
async listKeys(type?: AutomationKeyType) {
|
async listKeys(type?: AutomationKeyType) {
|
||||||
return this.execute<ListAutomationKeysRequest>({
|
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) {
|
async createKey(type: AutomationKeyType, label?: string) {
|
||||||
return this.execute<CreateAutomationKeyRequest>({
|
return this.execute<CreateAutomationKeyRequest>({
|
||||||
requestFn: () =>
|
requestFn: () =>
|
||||||
@@ -43,6 +59,9 @@ class AutomationApi extends BaseApiWithErrors {
|
|||||||
onSuccess: (response) => {
|
onSuccess: (response) => {
|
||||||
this.generatedSecret = response.data.secret;
|
this.generatedSecret = response.data.secret;
|
||||||
this.keys = [response.data.key, ...this.keys];
|
this.keys = [response.data.key, ...this.keys];
|
||||||
|
if (this.usage) {
|
||||||
|
this.usage = { ...this.usage, activeKeys: this.usage.activeKeys + 1 };
|
||||||
|
}
|
||||||
snackbar.success('snackbar.automation.created');
|
snackbar.success('snackbar.automation.created');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -54,6 +73,10 @@ class AutomationApi extends BaseApiWithErrors {
|
|||||||
logContext: 'revoking automation key',
|
logContext: 'revoking automation key',
|
||||||
onSuccess: (response) => {
|
onSuccess: (response) => {
|
||||||
this.keys = this.keys.map((key) => (key.id === response.data.id ? response.data : key));
|
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');
|
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';
|
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
|
||||||
|
|
||||||
export type ListAutomationKeysRequest = (typeof classroomio.organization)['automation']['keys']['$get'];
|
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 CreateAutomationKeyRequest = (typeof classroomio.organization)['automation']['keys']['$post'];
|
||||||
export type RevokeAutomationKeyRequest = (typeof classroomio.organization)['automation']['keys'][':keyId']['$delete'];
|
export type RevokeAutomationKeyRequest = (typeof classroomio.organization)['automation']['keys'][':keyId']['$delete'];
|
||||||
export type RotateAutomationKeyRequest =
|
export type RotateAutomationKeyRequest =
|
||||||
(typeof classroomio.organization)['automation']['keys'][':keyId']['rotate']['$post'];
|
(typeof classroomio.organization)['automation']['keys'][':keyId']['rotate']['$post'];
|
||||||
|
|
||||||
export type ListAutomationKeysSuccess = Extract<InferResponseType<ListAutomationKeysRequest>, { success: true }>;
|
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 CreateAutomationKeySuccess = Extract<InferResponseType<CreateAutomationKeyRequest>, { success: true }>;
|
||||||
export type RevokeAutomationKeySuccess = Extract<InferResponseType<RevokeAutomationKeyRequest>, { success: true }>;
|
export type RevokeAutomationKeySuccess = Extract<InferResponseType<RevokeAutomationKeyRequest>, { success: true }>;
|
||||||
export type RotateAutomationKeySuccess = Extract<InferResponseType<RotateAutomationKeyRequest>, { success: true }>;
|
export type RotateAutomationKeySuccess = Extract<InferResponseType<RotateAutomationKeyRequest>, { success: true }>;
|
||||||
|
|
||||||
export type AutomationKey = ListAutomationKeysSuccess['data'][number];
|
export type AutomationKey = ListAutomationKeysSuccess['data'][number];
|
||||||
export type AutomationKeyType = AutomationKey['type'];
|
export type AutomationKeyType = AutomationKey['type'];
|
||||||
|
export type AutomationUsage = GetAutomationUsageSuccess['data'];
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import { shortenName } from '$lib/utils/functions/string';
|
import { shortenName } from '$lib/utils/functions/string';
|
||||||
import { Vote } from '$features/ui';
|
import { Vote } from '$features/ui';
|
||||||
import { CircleCheckIcon } from '$features/ui/icons';
|
import { CircleCheckIcon } from '$features/ui/icons';
|
||||||
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
|
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
|
||||||
|
|
||||||
interface CommentAuthor {
|
interface CommentAuthor {
|
||||||
id: number | string | null;
|
id: number | string | null;
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
<article class="prose prose-sm sm:prose p-2">
|
<article class="prose prose-sm sm:prose p-2">
|
||||||
{@html sanitizeHtml(comment.body ?? '')}
|
<SafeHtmlContent content={comment.body ?? ''} />
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
import { CommunityDeleteModal, CommunityCommentItem } from '$features/community/components';
|
import { CommunityDeleteModal, CommunityCommentItem } from '$features/community/components';
|
||||||
import type { CommunityQuestionSuccess } from '../utils/types';
|
import type { CommunityQuestionSuccess } from '../utils/types';
|
||||||
import { currentCommunityQuestion } from '../utils/store';
|
import { currentCommunityQuestion } from '../utils/store';
|
||||||
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
|
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -211,7 +211,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<section class="prose prose-sm sm:prose p-2">
|
<section class="prose prose-sm sm:prose p-2">
|
||||||
{@html sanitizeHtml(communityApi.question?.body ?? '')}
|
<SafeHtmlContent content={communityApi.question?.body ?? ''} />
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
import getCurrencyFormatter from '$lib/utils/functions/getCurrencyFormatter';
|
import getCurrencyFormatter from '$lib/utils/functions/getCurrencyFormatter';
|
||||||
import { calcCourseProgress, calcProgressRate } from '$features/course/utils/functions';
|
import { calcCourseProgress, calcProgressRate } from '$features/course/utils/functions';
|
||||||
import CardDropdown from './card-dropdown.svelte';
|
import CardDropdown from './card-dropdown.svelte';
|
||||||
|
import CoursePublishBadge from './course-publish-badge.svelte';
|
||||||
import type { OrgCourses, UserEnrolledCourses } from '$features/course/types';
|
import type { OrgCourses, UserEnrolledCourses } from '$features/course/types';
|
||||||
import type { OrgPublicCourses } from '$features/org/utils/types';
|
import type { OrgPublicCourses } from '$features/org/utils/types';
|
||||||
|
|
||||||
@@ -196,13 +197,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<Badge variant={isPublished ? 'default' : 'outline'}>
|
<CoursePublishBadge {isPublished} />
|
||||||
{#if isPublished}
|
|
||||||
{$t('courses.course_card.published')}
|
|
||||||
{:else}
|
|
||||||
{$t('courses.course_card.unpublished')}
|
|
||||||
{/if}
|
|
||||||
</Badge>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { COURSE_SORT_OPTIONS } from '../utils/constants';
|
import { COURSE_SORT_OPTIONS } from '../utils/constants';
|
||||||
import * as Popover from '@cio/ui/base/popover';
|
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 { Button } from '@cio/ui/base/button';
|
||||||
import { Spinner } from '@cio/ui/base/spinner';
|
import { Spinner } from '@cio/ui/base/spinner';
|
||||||
import { Checkbox } from '@cio/ui/base/checkbox';
|
import { Checkbox } from '@cio/ui/base/checkbox';
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
selectedId?: string;
|
selectedId?: string;
|
||||||
|
selectedOrder?: 'asc' | 'desc';
|
||||||
selectedTags?: string[];
|
selectedTags?: string[];
|
||||||
tagGroups?: FilterTagGroup[];
|
tagGroups?: FilterTagGroup[];
|
||||||
sortOptions?: readonly SortOption[];
|
sortOptions?: readonly SortOption[];
|
||||||
@@ -37,6 +39,7 @@
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
selectedId = $bindable('0'),
|
selectedId = $bindable('0'),
|
||||||
|
selectedOrder = $bindable('desc' as 'asc' | 'desc'),
|
||||||
selectedTags = [],
|
selectedTags = [],
|
||||||
tagGroups = [],
|
tagGroups = [],
|
||||||
sortOptions = COURSE_SORT_OPTIONS,
|
sortOptions = COURSE_SORT_OPTIONS,
|
||||||
@@ -47,8 +50,7 @@
|
|||||||
|
|
||||||
let isOpen = $state(false);
|
let isOpen = $state(false);
|
||||||
|
|
||||||
const hasActiveFilters = $derived(selectedId !== '0' || selectedTags.length > 0);
|
const hasActiveFilters = $derived(selectedId !== '0' || selectedOrder !== 'desc' || selectedTags.length > 0);
|
||||||
const hasActiveTagFilters = $derived(selectedTags.length > 0);
|
|
||||||
|
|
||||||
function isTagSelected(tagSlug: string) {
|
function isTagSelected(tagSlug: string) {
|
||||||
return selectedTags.includes(tagSlug);
|
return selectedTags.includes(tagSlug);
|
||||||
@@ -66,7 +68,7 @@
|
|||||||
<FilterIcon size={16} />
|
<FilterIcon size={16} />
|
||||||
{/if}
|
{/if}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
{#if hasActiveTagFilters}
|
{#if hasActiveFilters}
|
||||||
<span
|
<span
|
||||||
class="ui:bg-primary absolute -top-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border border-white"
|
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"
|
aria-hidden="true"
|
||||||
@@ -103,6 +105,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="space-y-3">
|
||||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">{$t('courses.tag_filters.tags')}</p>
|
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">{$t('courses.tag_filters.tags')}</p>
|
||||||
{#if tagGroups.length === 0}
|
{#if tagGroups.length === 0}
|
||||||
|
|||||||
@@ -3,21 +3,35 @@
|
|||||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||||
import BellIcon from '@lucide/svelte/icons/bell';
|
import BellIcon from '@lucide/svelte/icons/bell';
|
||||||
import { Button } from '@cio/ui/base/button';
|
import { Button } from '@cio/ui/base/button';
|
||||||
|
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||||
import * as Popover from '@cio/ui/base/popover';
|
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 RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
|
||||||
import * as Empty from '@cio/ui/base/empty';
|
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 { setupProgressApi } from '$features/setup/api/setup-progress.svelte';
|
||||||
import { courseApi } from '$features/course/api';
|
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 siteName = $derived($currentOrg.siteName);
|
||||||
|
const showCoursePublishBadge = $derived(!$isStudentExperience);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!siteName) return;
|
if (!siteName) return;
|
||||||
|
|
||||||
setupProgressApi.fetchSetupProgress(siteName);
|
setupProgressApi.fetchSetupProgress(siteName);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function handlePreview() {
|
||||||
|
openCoursePreview({
|
||||||
|
courseId: courseApi.course?.id ?? '',
|
||||||
|
courseSlug: courseApi.course?.slug,
|
||||||
|
currentOrgDomain: $currentOrgDomain
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header
|
<header
|
||||||
@@ -30,13 +44,17 @@
|
|||||||
<Separator orientation="vertical" />
|
<Separator orientation="vertical" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
<p class="max-w-2xs truncate text-sm font-medium">
|
<p class="max-w-2xs truncate text-sm font-medium">
|
||||||
{courseApi.course?.title || ''}
|
{courseApi.course?.title || ''}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<span class="grow"></span>
|
{#if showCoursePublishBadge}
|
||||||
|
<CoursePublishBadge isPublished={courseApi.course?.isPublished} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Search />
|
<span class="grow"></span>
|
||||||
|
|
||||||
<Popover.Root>
|
<Popover.Root>
|
||||||
<Popover.Trigger>
|
<Popover.Trigger>
|
||||||
@@ -56,11 +74,24 @@
|
|||||||
<Empty.Content>
|
<Empty.Content>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
<RefreshCcwIcon />
|
<RefreshCcwIcon />
|
||||||
Refresh
|
{$t('common.refresh')}
|
||||||
</Button>
|
</Button>
|
||||||
</Empty.Content>
|
</Empty.Content>
|
||||||
</Empty.Root>
|
</Empty.Root>
|
||||||
</Popover.Content>
|
</Popover.Content>
|
||||||
</Popover.Root>
|
</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>
|
</div>
|
||||||
</header>
|
</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">
|
<script lang="ts">
|
||||||
import { questionnaire } from './store';
|
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 { t } from '$lib/utils/functions/translations';
|
||||||
|
|
||||||
import { InputField } from '@cio/ui/custom/input-field';
|
import { InputField } from '@cio/ui/custom/input-field';
|
||||||
@@ -49,9 +49,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<article class="preview prose prose-sm sm:prose mt-3 p-2">
|
<article class="preview prose prose-sm sm:prose mt-3 p-2">
|
||||||
{@html sanitizeHtml(
|
<SafeHtmlContent
|
||||||
$questionnaire.description || $t('course.navItem.lessons.exercises.all_exercises.description.no')
|
content={$questionnaire.description || $t('course.navItem.lessons.exercises.all_exercises.description.no')}
|
||||||
)}
|
/>
|
||||||
</article>
|
</article>
|
||||||
{:else}
|
{:else}
|
||||||
<InputField
|
<InputField
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
import { toApiPayload, type AnswerData } from '@cio/question-types';
|
import { toApiPayload, type AnswerData } from '@cio/question-types';
|
||||||
import { exerciseApi, presignApi } from '$features/course/api';
|
import { exerciseApi, presignApi } from '$features/course/api';
|
||||||
import type { SubmissionListItem } from '$features/course/utils/types';
|
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 { t } from '$lib/utils/functions/translations';
|
||||||
import { snackbar } from '$features/ui/snackbar/store';
|
import { snackbar } from '$features/ui/snackbar/store';
|
||||||
import { ContentType } from '@cio/utils/constants/content';
|
import { ContentType } from '@cio/utils/constants/content';
|
||||||
@@ -422,9 +422,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<article class="preview prose prose-sm sm:prose mt-3 p-2">
|
<article class="preview prose prose-sm sm:prose mt-3 p-2">
|
||||||
{@html sanitizeHtml(
|
<SafeHtmlContent
|
||||||
$questionnaire.description || $t('course.navItem.lessons.exercises.all_exercises.view_mode.no_description')
|
content={$questionnaire.description ||
|
||||||
)}
|
$t('course.navItem.lessons.exercises.all_exercises.view_mode.no_description')}
|
||||||
|
/>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<Button onclick={handleStart} type="button" class="float-right my-5">
|
<Button onclick={handleStart} type="button" class="float-right my-5">
|
||||||
|
|||||||
+15
-6
@@ -8,7 +8,7 @@
|
|||||||
import { lessonApi } from '$features/course/api';
|
import { lessonApi } from '$features/course/api';
|
||||||
import { diffLines } from 'diff';
|
import { diffLines } from 'diff';
|
||||||
import { courseApi } from '$features/course/api';
|
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 { t } from '$lib/utils/functions/translations';
|
||||||
|
|
||||||
import { snackbar } from '$features/ui/snackbar/store';
|
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 () => {
|
untrack(async () => {
|
||||||
try {
|
try {
|
||||||
isMoreHistoryLoading = true;
|
isMoreHistoryLoading = true;
|
||||||
@@ -100,9 +100,18 @@
|
|||||||
|
|
||||||
const data = response.data;
|
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 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]);
|
lessonHistory = removeDuplicate([...lessonHistory, ...newEntries]);
|
||||||
|
|
||||||
if (lessonHistory.length > 0) {
|
if (lessonHistory.length > 0) {
|
||||||
@@ -206,7 +215,7 @@
|
|||||||
{#key lessonId}
|
{#key lessonId}
|
||||||
<HTMLRender id="display" className="m-auto">
|
<HTMLRender id="display" className="m-auto">
|
||||||
<div class="amen">
|
<div class="amen">
|
||||||
{@html sanitizeHtml(content)}
|
<SafeHtmlContent {content} />
|
||||||
</div>
|
</div>
|
||||||
</HTMLRender>
|
</HTMLRender>
|
||||||
{/key}
|
{/key}
|
||||||
@@ -222,7 +231,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{#each lessonHistory as version, index}
|
{#each lessonHistory as version, index (index)}
|
||||||
<button
|
<button
|
||||||
onclick={() => updateContentVersion(version, index)}
|
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 ==
|
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">
|
<script lang="ts">
|
||||||
import { HTMLRender, TextEditor } from '$features/ui';
|
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 { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
|
||||||
import { lessonApi } from '$features/course/api';
|
import { lessonApi } from '$features/course/api';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
@@ -43,10 +43,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const content = $derived(lessonApi.translations[lessonId]?.[lessonApi.currentLocale] || '');
|
const content = $derived(lessonApi.translations[lessonId]?.[lessonApi.currentLocale] || '');
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
console.log('content', content);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if mode === MODES.edit}
|
{#if mode === MODES.edit}
|
||||||
@@ -67,7 +63,7 @@
|
|||||||
<!-- View Mode -->
|
<!-- View Mode -->
|
||||||
{#if !isHtmlValueEmpty(content)}
|
{#if !isHtmlValueEmpty(content)}
|
||||||
<HTMLRender>
|
<HTMLRender>
|
||||||
{@html sanitizeHtml(content)}
|
<SafeHtmlContent {content} />
|
||||||
</HTMLRender>
|
</HTMLRender>
|
||||||
{:else if hasAtLeastOneTranslation}
|
{:else if hasAtLeastOneTranslation}
|
||||||
<p class="text-md py-2 font-normal italic dark:text-white">
|
<p class="text-md py-2 font-normal italic dark:text-white">
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { Badge } from '@cio/ui/base/badge';
|
|
||||||
import * as Table from '@cio/ui/base/table';
|
import * as Table from '@cio/ui/base/table';
|
||||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||||
|
|
||||||
import { isMobileStore } from '@cio/ui/hooks/is-mobile.svelte';
|
import { isMobileStore } from '@cio/ui/hooks/is-mobile.svelte';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
|
import CoursePublishBadge from './course-publish-badge.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -88,13 +88,7 @@
|
|||||||
<Table.Cell>{totalLessons}</Table.Cell>
|
<Table.Cell>{totalLessons}</Table.Cell>
|
||||||
<Table.Cell>{totalStudents}</Table.Cell>
|
<Table.Cell>{totalStudents}</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Badge variant={isPublished ? 'default' : 'outline'}>
|
<CoursePublishBadge {isPublished} />
|
||||||
{#if isPublished}
|
|
||||||
{$t('courses.course_card.published')}
|
|
||||||
{:else}
|
|
||||||
{$t('courses.course_card.unpublished')}
|
|
||||||
{/if}
|
|
||||||
</Badge>
|
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
{/if}
|
{/if}
|
||||||
<Table.Cell class="text-center">
|
<Table.Cell class="text-center">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import { calDateDiff } from '$lib/utils/functions/date';
|
import { calDateDiff } from '$lib/utils/functions/date';
|
||||||
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
|
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 { t } from '$lib/utils/functions/translations';
|
||||||
|
|
||||||
import type { Feed } from '$features/course/utils/types';
|
import type { Feed } from '$features/course/utils/types';
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
{#if !isHtmlValueEmpty(feed.content || '')}
|
{#if !isHtmlValueEmpty(feed.content || '')}
|
||||||
<HTMLRender className="w-[80%]">
|
<HTMLRender className="w-[80%]">
|
||||||
<div>
|
<div>
|
||||||
{@html sanitizeHtml(feed.content || '')}
|
<SafeHtmlContent content={feed.content || ''} />
|
||||||
</div>
|
</div>
|
||||||
</HTMLRender>
|
</HTMLRender>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||||
import UpdateDescription from '$features/course/components/exercise/update-description.svelte';
|
import UpdateDescription from '$features/course/components/exercise/update-description.svelte';
|
||||||
import { ContentNavigationActions } from '$features/course/components/lesson';
|
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 { getOrderedNavigableContent } from '$features/course/utils/content';
|
||||||
import { Empty } from '@cio/ui/custom/empty';
|
import { Empty } from '@cio/ui/custom/empty';
|
||||||
import VideoIcon from '@lucide/svelte/icons/video';
|
import VideoIcon from '@lucide/svelte/icons/video';
|
||||||
@@ -266,6 +266,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<RefreshPageData />
|
||||||
</RoleBasedSecurity>
|
</RoleBasedSecurity>
|
||||||
</div>
|
</div>
|
||||||
</Page.Action>
|
</Page.Action>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
import { globalStore } from '$lib/utils/store/app';
|
import { globalStore } from '$lib/utils/store/app';
|
||||||
import { currentOrg } from '$lib/utils/store/org';
|
import { currentOrg } from '$lib/utils/store/org';
|
||||||
import { snackbar } from '$features/ui/snackbar/store';
|
import { snackbar } from '$features/ui/snackbar/store';
|
||||||
|
import { RefreshPageData } from '$features/ui';
|
||||||
import LessonVersionHistory from '$features/course/components/lesson/lesson-version-history.svelte';
|
import LessonVersionHistory from '$features/course/components/lesson/lesson-version-history.svelte';
|
||||||
import { courseApi, lessonApi } from '$features/course/api';
|
import { courseApi, lessonApi } from '$features/course/api';
|
||||||
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
|
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
|
||||||
@@ -260,6 +261,7 @@
|
|||||||
if (!lessonApi.lesson || !lessonId) return;
|
if (!lessonApi.lesson || !lessonId) return;
|
||||||
if (!lessonApi.isDirty) return;
|
if (!lessonApi.isDirty) return;
|
||||||
if ($isLoading) return;
|
if ($isLoading) return;
|
||||||
|
console.log('autoSave');
|
||||||
|
|
||||||
autoSave();
|
autoSave();
|
||||||
});
|
});
|
||||||
@@ -268,6 +270,7 @@
|
|||||||
let didHandleExitEdit = false;
|
let didHandleExitEdit = false;
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!lessonId) return;
|
if (!lessonId) return;
|
||||||
|
console.log('didHandleExitEdit');
|
||||||
|
|
||||||
const currentParam = $page.url.searchParams.get('mode');
|
const currentParam = $page.url.searchParams.get('mode');
|
||||||
const prev = prevModeParam;
|
const prev = prevModeParam;
|
||||||
@@ -345,8 +348,10 @@
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LanguageSelector />
|
<RefreshPageData onRefresh={() => lessonApi.get(courseId, lessonId)} />
|
||||||
</RoleBasedSecurity>
|
</RoleBasedSecurity>
|
||||||
|
|
||||||
|
<LanguageSelector />
|
||||||
</div>
|
</div>
|
||||||
</Page.Action>
|
</Page.Action>
|
||||||
</Page.Header>
|
</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 Select from '@cio/ui/base/select';
|
||||||
import * as Table from '@cio/ui/base/table';
|
import * as Table from '@cio/ui/base/table';
|
||||||
import { Empty } from '@cio/ui/custom/empty';
|
import { Empty } from '@cio/ui/custom/empty';
|
||||||
|
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||||
import { InputField } from '@cio/ui/custom/input-field';
|
import { InputField } from '@cio/ui/custom/input-field';
|
||||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||||
|
|
||||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||||
|
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||||
import TagIcon from '@lucide/svelte/icons/tag';
|
import TagIcon from '@lucide/svelte/icons/tag';
|
||||||
|
|
||||||
import { tagApi } from '$features/tag/api';
|
import { tagApi } from '$features/tag/api';
|
||||||
@@ -222,7 +224,7 @@
|
|||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each tagApi.tagGroups as group (group.id)}
|
{#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}>
|
<Table.Cell colspan={5}>
|
||||||
<div class="flex items-start justify-between gap-4 py-1">
|
<div class="flex items-start justify-between gap-4 py-1">
|
||||||
<div class="space-y-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>
|
<p class="ui:text-muted-foreground max-w-[320px] truncate text-xs">{group.description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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')}
|
{$t('tags_admin.actions.edit_group')}
|
||||||
</Button>
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
</div>
|
</div>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
@@ -246,7 +261,7 @@
|
|||||||
</Table.Row>
|
</Table.Row>
|
||||||
{:else}
|
{:else}
|
||||||
{#each group.tags as tag (tag.id)}
|
{#each group.tags as tag (tag.id)}
|
||||||
<Table.Row>
|
<Table.Row class="group">
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
@@ -269,9 +284,22 @@
|
|||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>{group.name}</Table.Cell>
|
<Table.Cell>{group.name}</Table.Cell>
|
||||||
<Table.Cell class="text-right">
|
<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')}
|
{$t('tags_admin.actions.edit_tag')}
|
||||||
</Button>
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -14,15 +14,23 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
activity: Activity;
|
activity: Activity;
|
||||||
className?: string;
|
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>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={cn(
|
class={cn(
|
||||||
'flex w-full flex-col items-center justify-evenly gap-2 rounded-xl border p-3 md:flex-row md:p-5',
|
'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
|
className,
|
||||||
|
classes.container
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div class="bg-primary-200 w-fit rounded-full p-4 text-black">
|
<div class="bg-primary-200 w-fit rounded-full p-4 text-black">
|
||||||
@@ -30,17 +38,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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}
|
{activity.title}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-center text-2xl md:text-left">
|
<p class={cn('text-center text-2xl md:text-left', classes.percentage)}>
|
||||||
{activity.percentage}
|
{activity.percentage}
|
||||||
{#if !activity.hidePercentage}
|
{#if !activity.hidePercentage}
|
||||||
%
|
%
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
{#if activity.description}
|
{#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}
|
{activity.description}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import * as Avatar from '@cio/ui/base/avatar';
|
import * as Avatar from '@cio/ui/base/avatar';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
@@ -19,7 +20,8 @@
|
|||||||
hideGoogleAuth?: boolean;
|
hideGoogleAuth?: boolean;
|
||||||
redirectPathname?: string;
|
redirectPathname?: string;
|
||||||
handleSubmit?: () => void;
|
handleSubmit?: () => void;
|
||||||
children?: import('svelte').Snippet;
|
children?: Snippet;
|
||||||
|
getPasswordAuthAlternative?: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -30,7 +32,8 @@
|
|||||||
hideGoogleAuth = false,
|
hideGoogleAuth = false,
|
||||||
redirectPathname = '',
|
redirectPathname = '',
|
||||||
handleSubmit = () => {},
|
handleSubmit = () => {},
|
||||||
children
|
children,
|
||||||
|
getPasswordAuthAlternative
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
async function signInWithGoogle() {
|
async function signInWithGoogle() {
|
||||||
@@ -100,12 +103,17 @@
|
|||||||
<Separator />
|
<Separator />
|
||||||
<span class="ui:bg-card ui:text-muted-foreground absolute px-2 text-sm"> Or continue With </span>
|
<span class="ui:bg-card ui:text-muted-foreground absolute px-2 text-sm"> Or continue With </span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if getPasswordAuthAlternative}
|
||||||
|
{@render getPasswordAuthAlternative()}
|
||||||
|
{:else}
|
||||||
<Button variant="outline" onclick={signInWithGoogle} disabled={isLoading} class="w-full">
|
<Button variant="outline" onclick={signInWithGoogle} disabled={isLoading} class="w-full">
|
||||||
<GoogleIconColored />
|
<GoogleIconColored />
|
||||||
<span>
|
<span>
|
||||||
{isLogin ? $t('login.login_with_google') : $t('login.signup_with_google')}
|
{isLogin ? $t('login.login_with_google') : $t('login.signup_with_google')}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
|
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
code?: string;
|
code?: string;
|
||||||
}
|
}
|
||||||
@@ -8,5 +9,5 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mb-3 rounded-md bg-gray-200 p-5 dark:bg-gray-500">
|
<div class="mb-3 rounded-md bg-gray-200 p-5 dark:bg-gray-500">
|
||||||
{@html sanitizeHtml(code)}
|
<SafeHtmlContent content={code} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+8
-4
@@ -32,6 +32,7 @@
|
|||||||
import { Button } from '@cio/ui/base/button';
|
import { Button } from '@cio/ui/base/button';
|
||||||
import { courseApi } from '$features/course/api';
|
import { courseApi } from '$features/course/api';
|
||||||
import { generateSlug } from '@cio/utils/functions';
|
import { generateSlug } from '@cio/utils/functions';
|
||||||
|
import { openCoursePreview } from '$features/course/utils/course-preview';
|
||||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||||
import { useSidebar } from '@cio/ui/base/sidebar';
|
import { useSidebar } from '@cio/ui/base/sidebar';
|
||||||
|
|
||||||
@@ -164,9 +165,12 @@
|
|||||||
syncCourseStore(course);
|
syncCourseStore(course);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePreview() {
|
function handlePreview() {
|
||||||
const link = `${$currentOrgDomain}/course/${course.slug}`;
|
openCoursePreview({
|
||||||
window.open(link, '_blank');
|
courseId,
|
||||||
|
courseSlug: course.slug,
|
||||||
|
currentOrgDomain: $currentOrgDomain
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function setter(value: any, setterKey: string) {
|
function setter(value: any, setterKey: string) {
|
||||||
@@ -193,7 +197,7 @@
|
|||||||
</Button>
|
</Button>
|
||||||
<HoverableItem>
|
<HoverableItem>
|
||||||
{#snippet children(isHovered)}
|
{#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} />
|
<PreviewIcon {isHovered} size={16} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
+4
-2
@@ -5,7 +5,7 @@
|
|||||||
import { isCourseFree, calcCourseDiscount } from '$lib/utils/functions/course';
|
import { isCourseFree, calcCourseDiscount } from '$lib/utils/functions/course';
|
||||||
import { currentOrg } from '$lib/utils/store/org';
|
import { currentOrg } from '$lib/utils/store/org';
|
||||||
import { goto } from '$app/navigation';
|
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 HTMLRender from '$features/ui/html-render.svelte';
|
||||||
import PaymentModal from './payment-modal.svelte';
|
import PaymentModal from './payment-modal.svelte';
|
||||||
import type { Course } from '$features/course/utils/types';
|
import type { Course } from '$features/course/utils/types';
|
||||||
@@ -175,7 +175,9 @@
|
|||||||
<!-- Gift Container -->
|
<!-- Gift Container -->
|
||||||
{#if courseData?.metadata?.reward?.show}
|
{#if courseData?.metadata?.reward?.show}
|
||||||
<div class="flex flex-col items-center border-t border-b border-gray-300 p-10">
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { resolve } from '$app/paths';
|
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 PlayIcon from '@lucide/svelte/icons/play';
|
||||||
|
|
||||||
import { getLectureNo } from '$features/course/utils/functions';
|
import { getLectureNo } from '$features/course/utils/functions';
|
||||||
@@ -215,7 +215,7 @@
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<ul class="list font-light">
|
<ul class="list font-light">
|
||||||
<HTMLRender>{@html sanitizeHtml(get(courseData, 'metadata.requirements', ''))}</HTMLRender>
|
<HTMLRender><SafeHtmlContent content={get(courseData, 'metadata.requirements', '')} /></HTMLRender>
|
||||||
</ul>
|
</ul>
|
||||||
</NavSection>
|
</NavSection>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -228,7 +228,7 @@
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<HTMLRender className="dark:text-white text-sm font-light">
|
<HTMLRender className="dark:text-white text-sm font-light">
|
||||||
{@html sanitizeHtml(get(courseData, 'metadata.description', ''))}
|
<SafeHtmlContent content={get(courseData, 'metadata.description', '')} />
|
||||||
</HTMLRender>
|
</HTMLRender>
|
||||||
</NavSection>
|
</NavSection>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -238,7 +238,7 @@
|
|||||||
<NavSection id="goals">
|
<NavSection id="goals">
|
||||||
<h3 class="mt-0 mb-3 text-2xl">{$t('course.navItem.landing_page.learn')}</h3>
|
<h3 class="mt-0 mb-3 text-2xl">{$t('course.navItem.landing_page.learn')}</h3>
|
||||||
<ul class="list font-light">
|
<ul class="list font-light">
|
||||||
<HTMLRender>{@html sanitizeHtml(get(courseData, 'metadata.goals', ''))}</HTMLRender>
|
<HTMLRender><SafeHtmlContent content={get(courseData, 'metadata.goals', '')} /></HTMLRender>
|
||||||
</ul>
|
</ul>
|
||||||
</NavSection>
|
</NavSection>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export * from './question';
|
|||||||
export { default as UploadWidget } from './upload-widget/upload-widget.svelte';
|
export { default as UploadWidget } from './upload-widget/upload-widget.svelte';
|
||||||
export { default as PageLoadProgress } from './page-load-progress.svelte';
|
export { default as PageLoadProgress } from './page-load-progress.svelte';
|
||||||
export { default as PoweredBy } from './upgrade-powered-by.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 RoleBasedSecurity } from './role-based-security.svelte';
|
||||||
export { default as Search } from './search.svelte';
|
export { default as Search } from './search.svelte';
|
||||||
export { default as SenjaEmbed } from './senja-embed.svelte';
|
export { default as SenjaEmbed } from './senja-embed.svelte';
|
||||||
|
|||||||
@@ -108,7 +108,36 @@ const baseNavConfig: NavItemConfig[] = [
|
|||||||
icon: BracesIcon,
|
icon: BracesIcon,
|
||||||
requiresAdmin: true,
|
requiresAdmin: true,
|
||||||
disableWhenNotAdmin: 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',
|
titleKey: 'org_navigation.settings',
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { invalidateAll } from '$app/navigation';
|
||||||
|
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
|
||||||
|
|
||||||
|
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||||
|
import { t } from '$lib/utils/functions/translations';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
disabled?: boolean;
|
||||||
|
onRefresh?: () => Promise<unknown> | unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { disabled = false, onRefresh }: Props = $props();
|
||||||
|
|
||||||
|
let isRefreshing = $state(false);
|
||||||
|
|
||||||
|
async function handleRefresh() {
|
||||||
|
if (disabled || isRefreshing) return;
|
||||||
|
|
||||||
|
isRefreshing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (onRefresh) {
|
||||||
|
await onRefresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await invalidateAll();
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
onclick={handleRefresh}
|
||||||
|
disabled={disabled || isRefreshing}
|
||||||
|
tooltip={t.get('common.refresh')}
|
||||||
|
tooltipSide="bottom"
|
||||||
|
aria-label={t.get('common.refresh')}
|
||||||
|
>
|
||||||
|
<RefreshCcwIcon class={isRefreshing ? 'animate-spin' : ''} size={16} />
|
||||||
|
</IconButton>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// SaaS default CSP domains, baked in at build time.
|
||||||
|
// Self-hosted (adapter-node) starts with empty lists; runtime domains
|
||||||
|
// are added via env vars in hooks.server.ts so pre-built Docker images stay configurable.
|
||||||
|
|
||||||
|
const saasDefaults = {
|
||||||
|
scriptSrc: [
|
||||||
|
'https://assets.cdn.clsrio.com',
|
||||||
|
'https://cdnjs.cloudflare.com',
|
||||||
|
'https://*.posthog.com',
|
||||||
|
'https://*.senja.io',
|
||||||
|
'https://umami.hz.oncws.com',
|
||||||
|
'https://www.youtube.com',
|
||||||
|
'https://youtube.com',
|
||||||
|
'https://google.com'
|
||||||
|
],
|
||||||
|
styleSrc: [
|
||||||
|
'https://cdn.plyr.io',
|
||||||
|
'https://unpkg.com/[email protected]/dist/katex.min.css',
|
||||||
|
'https://assets.cdn.clsrio.com/eqneditor_1.css',
|
||||||
|
'https://fonts.googleapis.com'
|
||||||
|
],
|
||||||
|
connectSrc: [
|
||||||
|
'https://*.classroomio.com',
|
||||||
|
'https://classroomio.com',
|
||||||
|
'https://app.classroomio.com',
|
||||||
|
'https://api.classroomio.com',
|
||||||
|
'https://pgrest.classroomio.com',
|
||||||
|
'https://play.classroomio.com',
|
||||||
|
'wss://*.classroomio.com',
|
||||||
|
'https://assets.cdn.clsrio.com',
|
||||||
|
'https://cdn.plyr.io',
|
||||||
|
'https://*.posthog.com',
|
||||||
|
'https://umami.hz.oncws.com',
|
||||||
|
'https://*.r2.cloudflarestorage.com',
|
||||||
|
'https://*.senja.io',
|
||||||
|
'https://*.ytimg.com',
|
||||||
|
'https://noembed.com'
|
||||||
|
],
|
||||||
|
frameSrc: [
|
||||||
|
'https://www.youtube.com',
|
||||||
|
'https://youtube.com',
|
||||||
|
'https://www.youtube-nocookie.com',
|
||||||
|
'https://www.google.com',
|
||||||
|
'https://google.com'
|
||||||
|
],
|
||||||
|
fontSrc: ['https://fonts.gstatic.com', 'https://cdn.plyr.io'],
|
||||||
|
mediaSrc: ['https:']
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {boolean} isSelfHosted
|
||||||
|
* @param {string | undefined} serverUrl - PUBLIC_SERVER_URL, added to connect-src for SaaS builds
|
||||||
|
*/
|
||||||
|
export function getCspDomains(isSelfHosted, serverUrl) {
|
||||||
|
if (isSelfHosted) {
|
||||||
|
return {
|
||||||
|
scriptSrc: [],
|
||||||
|
styleSrc: [],
|
||||||
|
connectSrc: [],
|
||||||
|
frameSrc: [],
|
||||||
|
fontSrc: [],
|
||||||
|
mediaSrc: [],
|
||||||
|
apiOrigin: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...saasDefaults,
|
||||||
|
apiOrigin: serverUrl ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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) => {
|
export const isHtmlValueEmpty = (html: string) => {
|
||||||
if (!html || typeof html !== 'string') return true;
|
if (!html || typeof html !== 'string') return true;
|
||||||
|
|
||||||
|
if (/<svg\b/i.test(html)) return false;
|
||||||
|
|
||||||
if (!document || !browser) return false;
|
if (!document || !browser) return false;
|
||||||
|
|
||||||
return getTextFromHTML(html) === '';
|
return getTextFromHTML(html) === '';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"common": {
|
||||||
|
"refresh": "Opdater",
|
||||||
"unsaved_changes": {
|
"unsaved_changes": {
|
||||||
"message": "Du har ikke-gemte ændringer. Er du sikker på, at du vil tage afsted?"
|
"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",
|
"placeholder": "Søg efter lektioner eller øvelser",
|
||||||
"empty": "Ingen resultater fundet"
|
"empty": "Ingen resultater fundet"
|
||||||
},
|
},
|
||||||
|
"header": {
|
||||||
|
"preview": "Preview course",
|
||||||
|
"preview_missing_slug": "Generate and save a course slug before previewing."
|
||||||
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"back_to_course": "Tilbage til kurset",
|
"back_to_course": "Tilbage til kurset",
|
||||||
"content_count": {
|
"content_count": {
|
||||||
@@ -1582,6 +1587,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"course_filter": {
|
"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",
|
"date_created": "Dato oprettet",
|
||||||
"published": "Udgivet",
|
"published": "Udgivet",
|
||||||
"lessons": "Lektioner"
|
"lessons": "Lektioner"
|
||||||
@@ -1592,6 +1603,9 @@
|
|||||||
"clear_all": "Ryd alle",
|
"clear_all": "Ryd alle",
|
||||||
"popover_title": "Filtre",
|
"popover_title": "Filtre",
|
||||||
"sort": "Sorter",
|
"sort": "Sorter",
|
||||||
|
"order": "Rækkefølge",
|
||||||
|
"ascending": "Asc",
|
||||||
|
"descending": "Desc",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"empty_tags": "Ingen tags tilgængelige"
|
"empty_tags": "Ingen tags tilgængelige"
|
||||||
}
|
}
|
||||||
@@ -2212,7 +2226,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"login": {
|
"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_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.",
|
"sso_required": "Log venligst ind med SSO. Adgangskodelogin er deaktiveret.",
|
||||||
"sign_in_with_sso": "Log ind med {provider}",
|
"sign_in_with_sso": "Log ind med {provider}",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"common": {
|
||||||
|
"refresh": "Aktualisieren",
|
||||||
"unsaved_changes": {
|
"unsaved_changes": {
|
||||||
"message": "Sie haben nicht gespeicherte Änderungen. Sind Sie sicher, dass Sie gehen möchten?"
|
"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",
|
"placeholder": "Suchen Sie nach Lektionen oder Übungen",
|
||||||
"empty": "Keine Ergebnisse gefunden"
|
"empty": "Keine Ergebnisse gefunden"
|
||||||
},
|
},
|
||||||
|
"header": {
|
||||||
|
"preview": "Preview course",
|
||||||
|
"preview_missing_slug": "Generate and save a course slug before previewing."
|
||||||
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"back_to_course": "Zurück zum Kurs",
|
"back_to_course": "Zurück zum Kurs",
|
||||||
"content_count": {
|
"content_count": {
|
||||||
@@ -1582,6 +1587,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"course_filter": {
|
"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",
|
"date_created": "Erstellungsdatum",
|
||||||
"published": "Veröffentlicht",
|
"published": "Veröffentlicht",
|
||||||
"lessons": "Lektionen"
|
"lessons": "Lektionen"
|
||||||
@@ -1592,6 +1603,9 @@
|
|||||||
"clear_all": "Alles löschen",
|
"clear_all": "Alles löschen",
|
||||||
"popover_title": "Filter",
|
"popover_title": "Filter",
|
||||||
"sort": "Sortieren",
|
"sort": "Sortieren",
|
||||||
|
"order": "Reihenfolge",
|
||||||
|
"ascending": "Asc",
|
||||||
|
"descending": "Desc",
|
||||||
"tags": "Schlagworte",
|
"tags": "Schlagworte",
|
||||||
"empty_tags": "Keine Tags verfügbar"
|
"empty_tags": "Keine Tags verfügbar"
|
||||||
}
|
}
|
||||||
@@ -2212,7 +2226,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"login": {
|
"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_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.",
|
"sso_required": "Bitte melden Sie sich mit SSO an. Die Passwortanmeldung ist deaktiviert.",
|
||||||
"sign_in_with_sso": "Melden Sie sich mit {provider} an",
|
"sign_in_with_sso": "Melden Sie sich mit {provider} an",
|
||||||
|
|||||||
@@ -93,6 +93,9 @@
|
|||||||
"placeholder": "Search"
|
"placeholder": "Search"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"common": {
|
||||||
|
"refresh": "Refresh"
|
||||||
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"welcome": "Welcome back",
|
"welcome": "Welcome back",
|
||||||
"email": "Your email",
|
"email": "Your email",
|
||||||
@@ -240,7 +243,7 @@
|
|||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"or_continue_with": "Or continue with email and password",
|
"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_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.",
|
"sso_required": "Please sign in with SSO. Password login is disabled.",
|
||||||
"sign_in_with_sso": "Sign in with {provider}",
|
"sign_in_with_sso": "Sign in with {provider}",
|
||||||
@@ -566,6 +569,7 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"create_tag": "Create Tag",
|
"create_tag": "Create Tag",
|
||||||
"create_group": "Create Tag Group",
|
"create_group": "Create Tag Group",
|
||||||
|
"actions_menu": "Tag actions",
|
||||||
"edit_group": "Edit Group",
|
"edit_group": "Edit Group",
|
||||||
"edit_tag": "Edit Tag",
|
"edit_tag": "Edit Tag",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
@@ -865,10 +869,19 @@
|
|||||||
"clear_all": "Clear all",
|
"clear_all": "Clear all",
|
||||||
"popover_title": "Filters",
|
"popover_title": "Filters",
|
||||||
"sort": "Sort",
|
"sort": "Sort",
|
||||||
|
"order": "Order",
|
||||||
|
"ascending": "Asc",
|
||||||
|
"descending": "Desc",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"empty_tags": "No tags available"
|
"empty_tags": "No tags available"
|
||||||
},
|
},
|
||||||
"course_filter": {
|
"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",
|
"date_created": "Date Created",
|
||||||
"published": "Published",
|
"published": "Published",
|
||||||
"lessons": "Lessons"
|
"lessons": "Lessons"
|
||||||
@@ -947,6 +960,10 @@
|
|||||||
"placeholder": "Search lessons or exercises",
|
"placeholder": "Search lessons or exercises",
|
||||||
"empty": "No results found"
|
"empty": "No results found"
|
||||||
},
|
},
|
||||||
|
"header": {
|
||||||
|
"preview": "Preview course",
|
||||||
|
"preview_missing_slug": "Generate and save a course slug before previewing."
|
||||||
|
},
|
||||||
"navItem": {
|
"navItem": {
|
||||||
"news_feed": {
|
"news_feed": {
|
||||||
"heading": "News Feed",
|
"heading": "News Feed",
|
||||||
@@ -2083,6 +2100,10 @@
|
|||||||
"created_at_label": "Created:",
|
"created_at_label": "Created:",
|
||||||
"last_used_label": "Last used:",
|
"last_used_label": "Last used:",
|
||||||
"never_used": "Never",
|
"never_used": "Never",
|
||||||
|
"table_key": "Key",
|
||||||
|
"table_status": "Status",
|
||||||
|
"table_created": "Created",
|
||||||
|
"table_last_used": "Last used",
|
||||||
"scopes_label": "Scopes:",
|
"scopes_label": "Scopes:",
|
||||||
"revoke_confirm": "Revoke this automation key?",
|
"revoke_confirm": "Revoke this automation key?",
|
||||||
"rotate_confirm": "Rotate 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.",
|
"keys_description": "Use one MCP key per integration so access can be rotated or revoked cleanly.",
|
||||||
"rate_limits_title": "Rate limits",
|
"rate_limits_title": "Rate limits",
|
||||||
"rate_limits_description": "Read / write / publish requests allowed per minute.",
|
"rate_limits_description": "Read / write / publish requests allowed per minute.",
|
||||||
"used_label": "Used:",
|
"used_label": "Used:"
|
||||||
"per_key_label": "Per key:",
|
|
||||||
"per_org_label": "Per org:"
|
|
||||||
},
|
},
|
||||||
"keys": {
|
"keys": {
|
||||||
"title": "MCP keys",
|
"title": "MCP keys",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"common": {
|
||||||
|
"refresh": "Actualizar",
|
||||||
"unsaved_changes": {
|
"unsaved_changes": {
|
||||||
"message": "Tienes cambios sin guardar. ¿Estás seguro de que quieres irte?"
|
"message": "Tienes cambios sin guardar. ¿Estás seguro de que quieres irte?"
|
||||||
}
|
}
|
||||||
@@ -1508,6 +1509,10 @@
|
|||||||
"placeholder": "Buscar lecciones o ejercicios",
|
"placeholder": "Buscar lecciones o ejercicios",
|
||||||
"empty": "No se encontraron resultados"
|
"empty": "No se encontraron resultados"
|
||||||
},
|
},
|
||||||
|
"header": {
|
||||||
|
"preview": "Preview course",
|
||||||
|
"preview_missing_slug": "Generate and save a course slug before previewing."
|
||||||
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"back_to_course": "volver al curso",
|
"back_to_course": "volver al curso",
|
||||||
"content_count": {
|
"content_count": {
|
||||||
@@ -1582,6 +1587,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"course_filter": {
|
"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",
|
"date_created": "Fecha de creación",
|
||||||
"published": "Publicado",
|
"published": "Publicado",
|
||||||
"lessons": "Lecciones"
|
"lessons": "Lecciones"
|
||||||
@@ -1592,6 +1603,9 @@
|
|||||||
"clear_all": "Borrar todo",
|
"clear_all": "Borrar todo",
|
||||||
"popover_title": "Filtros",
|
"popover_title": "Filtros",
|
||||||
"sort": "ordenar",
|
"sort": "ordenar",
|
||||||
|
"order": "Orden",
|
||||||
|
"ascending": "Asc",
|
||||||
|
"descending": "Desc",
|
||||||
"tags": "Etiquetas",
|
"tags": "Etiquetas",
|
||||||
"empty_tags": "No hay etiquetas disponibles"
|
"empty_tags": "No hay etiquetas disponibles"
|
||||||
}
|
}
|
||||||
@@ -2212,7 +2226,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"login": {
|
"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_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.",
|
"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}",
|
"sign_in_with_sso": "Iniciar sesión con {proveedor}",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"common": {
|
||||||
|
"refresh": "Actualiser",
|
||||||
"unsaved_changes": {
|
"unsaved_changes": {
|
||||||
"message": "Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir partir ?"
|
"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",
|
"placeholder": "Rechercher des leçons ou des exercices",
|
||||||
"empty": "Aucun résultat trouvé"
|
"empty": "Aucun résultat trouvé"
|
||||||
},
|
},
|
||||||
|
"header": {
|
||||||
|
"preview": "Preview course",
|
||||||
|
"preview_missing_slug": "Generate and save a course slug before previewing."
|
||||||
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"back_to_course": "Retour au cours",
|
"back_to_course": "Retour au cours",
|
||||||
"content_count": {
|
"content_count": {
|
||||||
@@ -1582,6 +1587,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"course_filter": {
|
"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",
|
"date_created": "Date de création",
|
||||||
"published": "Publié",
|
"published": "Publié",
|
||||||
"lessons": "Leçons"
|
"lessons": "Leçons"
|
||||||
@@ -1592,6 +1603,9 @@
|
|||||||
"clear_all": "Tout effacer",
|
"clear_all": "Tout effacer",
|
||||||
"popover_title": "Filtres",
|
"popover_title": "Filtres",
|
||||||
"sort": "Trier",
|
"sort": "Trier",
|
||||||
|
"order": "Ordre",
|
||||||
|
"ascending": "Asc",
|
||||||
|
"descending": "Desc",
|
||||||
"tags": "Balises",
|
"tags": "Balises",
|
||||||
"empty_tags": "Aucune balise disponible"
|
"empty_tags": "Aucune balise disponible"
|
||||||
}
|
}
|
||||||
@@ -2212,7 +2226,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"login": {
|
"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_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.",
|
"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}",
|
"sign_in_with_sso": "Connectez-vous avec {provider}",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"common": {
|
||||||
|
"refresh": "रीफ़्रेश करें",
|
||||||
"unsaved_changes": {
|
"unsaved_changes": {
|
||||||
"message": "आपके पास सहेजे नहीं गए परिवर्तन हैं. क्या आपकी वाकई छोड़ने की इच्छा है?"
|
"message": "आपके पास सहेजे नहीं गए परिवर्तन हैं. क्या आपकी वाकई छोड़ने की इच्छा है?"
|
||||||
}
|
}
|
||||||
@@ -1508,6 +1509,10 @@
|
|||||||
"placeholder": "पाठ या अभ्यास खोजें",
|
"placeholder": "पाठ या अभ्यास खोजें",
|
||||||
"empty": "कोई परिणाम नहीं मिला"
|
"empty": "कोई परिणाम नहीं मिला"
|
||||||
},
|
},
|
||||||
|
"header": {
|
||||||
|
"preview": "Preview course",
|
||||||
|
"preview_missing_slug": "Generate and save a course slug before previewing."
|
||||||
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"back_to_course": "पाठ्यक्रम पर वापस जाएँ",
|
"back_to_course": "पाठ्यक्रम पर वापस जाएँ",
|
||||||
"content_count": {
|
"content_count": {
|
||||||
@@ -1582,6 +1587,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"course_filter": {
|
"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": "सबक"
|
||||||
@@ -1592,6 +1603,9 @@
|
|||||||
"clear_all": "सब साफ़ करें",
|
"clear_all": "सब साफ़ करें",
|
||||||
"popover_title": "फिल्टर",
|
"popover_title": "फिल्टर",
|
||||||
"sort": "क्रमबद्ध करें",
|
"sort": "क्रमबद्ध करें",
|
||||||
|
"order": "क्रम",
|
||||||
|
"ascending": "Asc",
|
||||||
|
"descending": "Desc",
|
||||||
"tags": "टैग",
|
"tags": "टैग",
|
||||||
"empty_tags": "कोई टैग उपलब्ध नहीं है"
|
"empty_tags": "कोई टैग उपलब्ध नहीं है"
|
||||||
}
|
}
|
||||||
@@ -2212,7 +2226,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"sso_enabled": "आपके डोमेन के लिए {प्रदाता} सक्षम है",
|
|
||||||
"sso_redirect_error": "SSO साइन-इन ग़लत कॉन्फ़िगर किया गया है. कृपया अपने एपीआई सर्वर पर PUBLIC_SERVER_URL सेट करें।",
|
"sso_redirect_error": "SSO साइन-इन ग़लत कॉन्फ़िगर किया गया है. कृपया अपने एपीआई सर्वर पर PUBLIC_SERVER_URL सेट करें।",
|
||||||
"sso_required": "कृपया एसएसओ के साथ साइन इन करें। पासवर्ड लॉगिन अक्षम है.",
|
"sso_required": "कृपया एसएसओ के साथ साइन इन करें। पासवर्ड लॉगिन अक्षम है.",
|
||||||
"sign_in_with_sso": "{प्रदाता} के साथ साइन इन करें",
|
"sign_in_with_sso": "{प्रदाता} के साथ साइन इन करें",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"common": {
|
||||||
|
"refresh": "Odśwież",
|
||||||
"unsaved_changes": {
|
"unsaved_changes": {
|
||||||
"message": "Masz niezapisane zmiany. Czy na pewno chcesz wyjść?"
|
"message": "Masz niezapisane zmiany. Czy na pewno chcesz wyjść?"
|
||||||
}
|
}
|
||||||
@@ -1508,6 +1509,10 @@
|
|||||||
"placeholder": "Wyszukaj lekcje lub ćwiczenia",
|
"placeholder": "Wyszukaj lekcje lub ćwiczenia",
|
||||||
"empty": "Nie znaleziono żadnych wyników"
|
"empty": "Nie znaleziono żadnych wyników"
|
||||||
},
|
},
|
||||||
|
"header": {
|
||||||
|
"preview": "Preview course",
|
||||||
|
"preview_missing_slug": "Generate and save a course slug before previewing."
|
||||||
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"back_to_course": "Powrót na kurs",
|
"back_to_course": "Powrót na kurs",
|
||||||
"content_count": {
|
"content_count": {
|
||||||
@@ -1582,6 +1587,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"course_filter": {
|
"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",
|
"date_created": "Data utworzenia",
|
||||||
"published": "Opublikowano",
|
"published": "Opublikowano",
|
||||||
"lessons": "Lekcje"
|
"lessons": "Lekcje"
|
||||||
@@ -1592,6 +1603,9 @@
|
|||||||
"clear_all": "Wyczyść wszystko",
|
"clear_all": "Wyczyść wszystko",
|
||||||
"popover_title": "Filtry",
|
"popover_title": "Filtry",
|
||||||
"sort": "Sortuj",
|
"sort": "Sortuj",
|
||||||
|
"order": "Kolejność",
|
||||||
|
"ascending": "Asc",
|
||||||
|
"descending": "Desc",
|
||||||
"tags": "Tagi",
|
"tags": "Tagi",
|
||||||
"empty_tags": "Brak dostępnych tagów"
|
"empty_tags": "Brak dostępnych tagów"
|
||||||
}
|
}
|
||||||
@@ -2212,7 +2226,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"login": {
|
"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_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.",
|
"sso_required": "Proszę zalogować się za pomocą SSO. Logowanie hasłem jest wyłączone.",
|
||||||
"sign_in_with_sso": "Zaloguj się przez {provider}",
|
"sign_in_with_sso": "Zaloguj się przez {provider}",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"common": {
|
||||||
|
"refresh": "Atualizar",
|
||||||
"unsaved_changes": {
|
"unsaved_changes": {
|
||||||
"message": "Você tem alterações não salvas. Tem certeza de que deseja sair?"
|
"message": "Você tem alterações não salvas. Tem certeza de que deseja sair?"
|
||||||
}
|
}
|
||||||
@@ -1508,6 +1509,10 @@
|
|||||||
"placeholder": "Pesquise aulas ou exercícios",
|
"placeholder": "Pesquise aulas ou exercícios",
|
||||||
"empty": "Nenhum resultado encontrado"
|
"empty": "Nenhum resultado encontrado"
|
||||||
},
|
},
|
||||||
|
"header": {
|
||||||
|
"preview": "Preview course",
|
||||||
|
"preview_missing_slug": "Generate and save a course slug before previewing."
|
||||||
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"back_to_course": "De volta ao curso",
|
"back_to_course": "De volta ao curso",
|
||||||
"content_count": {
|
"content_count": {
|
||||||
@@ -1582,6 +1587,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"course_filter": {
|
"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",
|
"date_created": "Data de criação",
|
||||||
"published": "Publicado",
|
"published": "Publicado",
|
||||||
"lessons": "Lições"
|
"lessons": "Lições"
|
||||||
@@ -1592,6 +1603,9 @@
|
|||||||
"clear_all": "Limpar tudo",
|
"clear_all": "Limpar tudo",
|
||||||
"popover_title": "Filtros",
|
"popover_title": "Filtros",
|
||||||
"sort": "Classificar",
|
"sort": "Classificar",
|
||||||
|
"order": "Ordem",
|
||||||
|
"ascending": "Asc",
|
||||||
|
"descending": "Desc",
|
||||||
"tags": "Etiquetas",
|
"tags": "Etiquetas",
|
||||||
"empty_tags": "Nenhuma tag disponível"
|
"empty_tags": "Nenhuma tag disponível"
|
||||||
}
|
}
|
||||||
@@ -2212,7 +2226,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"login": {
|
"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_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.",
|
"sso_required": "Faça login com SSO. O login por senha está desabilitado.",
|
||||||
"sign_in_with_sso": "Faça login com {provedor}",
|
"sign_in_with_sso": "Faça login com {provedor}",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"common": {
|
||||||
|
"refresh": "Обновить",
|
||||||
"unsaved_changes": {
|
"unsaved_changes": {
|
||||||
"message": "У вас есть несохраненные изменения. Вы уверены, что хотите уйти?"
|
"message": "У вас есть несохраненные изменения. Вы уверены, что хотите уйти?"
|
||||||
}
|
}
|
||||||
@@ -1508,6 +1509,10 @@
|
|||||||
"placeholder": "Поиск уроков или упражнений",
|
"placeholder": "Поиск уроков или упражнений",
|
||||||
"empty": "Результаты не найдены"
|
"empty": "Результаты не найдены"
|
||||||
},
|
},
|
||||||
|
"header": {
|
||||||
|
"preview": "Preview course",
|
||||||
|
"preview_missing_slug": "Generate and save a course slug before previewing."
|
||||||
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"back_to_course": "Вернуться к курсу",
|
"back_to_course": "Вернуться к курсу",
|
||||||
"content_count": {
|
"content_count": {
|
||||||
@@ -1582,6 +1587,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"course_filter": {
|
"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": "Уроки"
|
||||||
@@ -1592,6 +1603,9 @@
|
|||||||
"clear_all": "Очистить все",
|
"clear_all": "Очистить все",
|
||||||
"popover_title": "Фильтры",
|
"popover_title": "Фильтры",
|
||||||
"sort": "Сортировать",
|
"sort": "Сортировать",
|
||||||
|
"order": "Порядок",
|
||||||
|
"ascending": "Asc",
|
||||||
|
"descending": "Desc",
|
||||||
"tags": "Теги",
|
"tags": "Теги",
|
||||||
"empty_tags": "Нет доступных тегов"
|
"empty_tags": "Нет доступных тегов"
|
||||||
}
|
}
|
||||||
@@ -2212,7 +2226,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"sso_enabled": "{provider} включен для вашего домена",
|
|
||||||
"sso_redirect_error": "Вход в систему единого входа настроен неправильно. Установите PUBLIC_SERVER_URL для вашего сервера API.",
|
"sso_redirect_error": "Вход в систему единого входа настроен неправильно. Установите PUBLIC_SERVER_URL для вашего сервера API.",
|
||||||
"sso_required": "Пожалуйста, войдите с помощью SSO. Вход по паролю отключен.",
|
"sso_required": "Пожалуйста, войдите с помощью SSO. Вход по паролю отключен.",
|
||||||
"sign_in_with_sso": "Войдите через {provider}",
|
"sign_in_with_sso": "Войдите через {provider}",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"common": {
|
||||||
|
"refresh": "Làm mới",
|
||||||
"unsaved_changes": {
|
"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?"
|
"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",
|
"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"
|
"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": {
|
"sidebar": {
|
||||||
"back_to_course": "Quay lại khóa học",
|
"back_to_course": "Quay lại khóa học",
|
||||||
"content_count": {
|
"content_count": {
|
||||||
@@ -1582,6 +1587,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"course_filter": {
|
"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",
|
"date_created": "Ngày tạo",
|
||||||
"published": "Đã xuất bản",
|
"published": "Đã xuất bản",
|
||||||
"lessons": "Bài học"
|
"lessons": "Bài học"
|
||||||
@@ -1592,6 +1603,9 @@
|
|||||||
"clear_all": "Xóa tất cả",
|
"clear_all": "Xóa tất cả",
|
||||||
"popover_title": "Bộ lọc",
|
"popover_title": "Bộ lọc",
|
||||||
"sort": "Sắp xếp",
|
"sort": "Sắp xếp",
|
||||||
|
"order": "Thứ tự",
|
||||||
|
"ascending": "Asc",
|
||||||
|
"descending": "Desc",
|
||||||
"tags": "Thẻ",
|
"tags": "Thẻ",
|
||||||
"empty_tags": "Không có thẻ nào"
|
"empty_tags": "Không có thẻ nào"
|
||||||
}
|
}
|
||||||
@@ -2212,7 +2226,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"login": {
|
"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_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.",
|
"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}",
|
"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 { Button } from '@cio/ui/base/button';
|
||||||
import { ssoApi } from '$features/org/api/sso.svelte';
|
import { ssoApi } from '$features/org/api/sso.svelte';
|
||||||
import ShieldIcon from '@lucide/svelte/icons/shield';
|
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 emailFromUrl = page.url.searchParams.get('email') ?? '';
|
||||||
const isEmailPrefilled = !!emailFromUrl;
|
const isEmailPrefilled = !!emailFromUrl;
|
||||||
@@ -24,102 +26,45 @@
|
|||||||
let submitError: string | undefined = $state();
|
let submitError: string | undefined = $state();
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let errors = $state(Object.assign({}, LOGIN_FIELDS));
|
let errors = $state(Object.assign({}, LOGIN_FIELDS));
|
||||||
let isSSOInfoLoaded = $state(false);
|
/** Per-email discovery result; display uses this over org-level when set */
|
||||||
let orgSupportsSso = $state(false);
|
let discoveryState = $state<SsoAuthState | null>(null);
|
||||||
|
|
||||||
// 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
|
|
||||||
});
|
|
||||||
|
|
||||||
const redirectUrl = $derived(page.url.searchParams.get('redirect'));
|
const redirectUrl = $derived(page.url.searchParams.get('redirect'));
|
||||||
|
|
||||||
const hideGoogleAuth = $derived(!!($globalStore.isOrgSite && $currentOrg.disableGoogleAuth));
|
const hideGoogleAuth = $derived(!!($globalStore.isOrgSite && $currentOrg.disableGoogleAuth));
|
||||||
|
|
||||||
async function initSSOInfo() {
|
const ssoState = $derived(discoveryState ?? $authSsoStore.ssoState);
|
||||||
if (isSSOInfoLoaded || !$currentOrg.id) return;
|
const orgSupportsSso = $derived($authSsoStore.orgSupportsSso);
|
||||||
|
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
initSSOInfo();
|
const orgId = $currentOrg.id;
|
||||||
|
if (orgId) ensureSsoInfoLoaded(orgId, (id) => ssoApi.getOrgSsoInfo(id));
|
||||||
});
|
});
|
||||||
|
|
||||||
let emailCheckTimeout: ReturnType<typeof setTimeout>;
|
const handleEmailChange = createSsoEmailChecker({
|
||||||
function handleEmailChange() {
|
getEmail: () => fields.email,
|
||||||
|
getOrgSupportsSso: () => orgSupportsSso,
|
||||||
|
discoverSso: (email) => ssoApi.discoverSso(email),
|
||||||
|
onChecking: () => {
|
||||||
|
discoveryState = { ...$authSsoStore.ssoState, checking: true };
|
||||||
|
},
|
||||||
|
onResult: (state) => {
|
||||||
|
discoveryState = state;
|
||||||
|
},
|
||||||
|
onClear: () => {
|
||||||
errors.email = '';
|
errors.email = '';
|
||||||
if (!orgSupportsSso) return;
|
discoveryState = null;
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function handleSsoLogin() {
|
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,
|
email: fields.email,
|
||||||
providerId: ssoState.providerId || '',
|
providerId: ssoState.providerId || '',
|
||||||
callbackURL: url
|
callbackURL: url
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('res', res);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
@@ -182,7 +127,32 @@
|
|||||||
<title>Welcome back to {$currentOrg.name || 'ClassroomIO'}</title>
|
<title>Welcome back to {$currentOrg.name || 'ClassroomIO'}</title>
|
||||||
</svelte:head>
|
</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">
|
<div class="ui:flex ui:flex-col ui:gap-6">
|
||||||
<Field.Field>
|
<Field.Field>
|
||||||
<Field.Label for="email">{$t('login.email')}</Field.Label>
|
<Field.Label for="email">{$t('login.email')}</Field.Label>
|
||||||
@@ -205,44 +175,6 @@
|
|||||||
</Field.Content>
|
</Field.Content>
|
||||||
</Field.Field>
|
</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) -->
|
<!-- Password Section (when not force SSO and email/password not disabled) -->
|
||||||
{#if !ssoState.required && !($globalStore.isOrgSite && $currentOrg.disableEmailPassword)}
|
{#if !ssoState.required && !($globalStore.isOrgSite && $currentOrg.disableEmailPassword)}
|
||||||
<Field.Field>
|
<Field.Field>
|
||||||
|
|||||||
@@ -16,6 +16,10 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { resolve } from '$app/paths';
|
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();
|
let { data } = $props();
|
||||||
const emailFromUrl = page.url.searchParams.get('email') ?? '';
|
const emailFromUrl = page.url.searchParams.get('email') ?? '';
|
||||||
@@ -29,6 +33,9 @@
|
|||||||
} = $state({});
|
} = $state({});
|
||||||
let submitError: string = $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 disableSubmit = $derived(getDisableSubmit(fields));
|
||||||
const redirectUrl = $derived(page.url.searchParams.get('redirect'));
|
const redirectUrl = $derived(page.url.searchParams.get('redirect'));
|
||||||
const inviteToken = $derived(page.url.searchParams.get('invite_token'));
|
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)
|
// 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 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
|
// Invite context: allow signup when invite_token present or redirect contains invite info
|
||||||
const hasInviteContext = $derived(
|
const hasInviteContext = $derived(
|
||||||
!!inviteToken || (!!redirectUrl && (redirectUrl.includes('/invite/t/') || redirectUrl.includes('invite_token')))
|
!!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() {
|
async function handleSubmit() {
|
||||||
if (signupRestricted) {
|
if (signupRestricted) {
|
||||||
submitError = t.get('settings.auth.login.signup_disabled_error');
|
submitError = t.get('settings.auth.login.signup_disabled_error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ssoState.required && ssoState.available) {
|
||||||
|
handleSsoLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const validationRes = authValidation(fields);
|
const validationRes = authValidation(fields);
|
||||||
console.log('validationRes', validationRes);
|
console.log('validationRes', validationRes);
|
||||||
|
|
||||||
@@ -151,7 +197,31 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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">
|
<div class="ui:flex ui:flex-col ui:gap-6">
|
||||||
<Field.Field>
|
<Field.Field>
|
||||||
<Field.Label for="email">{$t('login.fields.email')}</Field.Label>
|
<Field.Label for="email">{$t('login.fields.email')}</Field.Label>
|
||||||
@@ -160,6 +230,7 @@
|
|||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
bind:value={fields.email}
|
bind:value={fields.email}
|
||||||
|
oninput={handleEmailChange}
|
||||||
placeholder="[email protected]"
|
placeholder="[email protected]"
|
||||||
disabled={loading || isEmailPrefilled}
|
disabled={loading || isEmailPrefilled}
|
||||||
readonly={isEmailPrefilled}
|
readonly={isEmailPrefilled}
|
||||||
@@ -173,6 +244,7 @@
|
|||||||
</Field.Content>
|
</Field.Content>
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
|
|
||||||
|
{#if !ssoState.required}
|
||||||
<Field.Field>
|
<Field.Field>
|
||||||
<Field.Label for="password">{$t('login.fields.password')}</Field.Label>
|
<Field.Label for="password">{$t('login.fields.password')}</Field.Label>
|
||||||
<Field.Content>
|
<Field.Content>
|
||||||
@@ -215,6 +287,7 @@
|
|||||||
<Button type="submit" disabled={disableSubmit || loading} {loading} class="ui:w-full">
|
<Button type="submit" disabled={disableSubmit || loading} {loading} class="ui:w-full">
|
||||||
{$t('login.create_account')}
|
{$t('login.create_account')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</AuthUI>
|
</AuthUI>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import { NewsFeedPage } from '$features/course/pages';
|
import { NewsFeedPage } from '$features/course/pages';
|
||||||
import { Button } from '@cio/ui/base/button';
|
import { Button } from '@cio/ui/base/button';
|
||||||
import * as Page from '@cio/ui/base/page';
|
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 { currentOrg } from '$lib/utils/store/org';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import type { AccountOrg } from '$features/app/types';
|
import type { AccountOrg } from '$features/app/types';
|
||||||
@@ -25,7 +26,7 @@
|
|||||||
<RoleBasedSecurity
|
<RoleBasedSecurity
|
||||||
allowedRoles={getPageRoles($currentOrg)}
|
allowedRoles={getPageRoles($currentOrg)}
|
||||||
onDenied={() => {
|
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">
|
<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.Title>
|
||||||
</Page.HeaderContent>
|
</Page.HeaderContent>
|
||||||
<Page.Action>
|
<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')}
|
{$t('course.navItem.news_feed.heading_button.title')}
|
||||||
</Button>
|
</Button>
|
||||||
|
<RefreshPageData onRefresh={() => newsfeedApi.list(data.courseId)} />
|
||||||
|
</div>
|
||||||
</Page.Action>
|
</Page.Action>
|
||||||
</Page.Header>
|
</Page.Header>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AnalyticsPage } from '$features/course/pages';
|
import { AnalyticsPage } from '$features/course/pages';
|
||||||
|
import { RefreshPageData } from '$features/ui';
|
||||||
import * as Page from '@cio/ui/base/page';
|
import * as Page from '@cio/ui/base/page';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
|
|
||||||
@@ -13,6 +14,9 @@
|
|||||||
{$t('analytics.title')}
|
{$t('analytics.title')}
|
||||||
</Page.Title>
|
</Page.Title>
|
||||||
</Page.HeaderContent>
|
</Page.HeaderContent>
|
||||||
|
<Page.Action>
|
||||||
|
<RefreshPageData />
|
||||||
|
</Page.Action>
|
||||||
</Page.Header>
|
</Page.Header>
|
||||||
<Page.Body>
|
<Page.Body>
|
||||||
{#snippet child()}
|
{#snippet child()}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AttendancePage } from '$features/course/pages';
|
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 * as Page from '@cio/ui/base/page';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
|
import { profile } from '$lib/utils/store/user';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
</script>
|
</script>
|
||||||
@@ -13,6 +16,9 @@
|
|||||||
{$t('course.navItem.attendance.title')}
|
{$t('course.navItem.attendance.title')}
|
||||||
</Page.Title>
|
</Page.Title>
|
||||||
</Page.HeaderContent>
|
</Page.HeaderContent>
|
||||||
|
<Page.Action>
|
||||||
|
<RefreshPageData onRefresh={() => courseApi.refreshCourse(data.courseId, $profile.id)} />
|
||||||
|
</Page.Action>
|
||||||
</Page.Header>
|
</Page.Header>
|
||||||
<Page.Body>
|
<Page.Body>
|
||||||
{#snippet child()}
|
{#snippet child()}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CertificatesPage } from '$features/course/pages';
|
import { CertificatesPage } from '$features/course/pages';
|
||||||
|
import { RefreshPageData } from '$features/ui';
|
||||||
import * as Page from '@cio/ui/base/page';
|
import * as Page from '@cio/ui/base/page';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
</script>
|
</script>
|
||||||
@@ -11,6 +12,9 @@
|
|||||||
{$t('course.navItem.certificates.title')}
|
{$t('course.navItem.certificates.title')}
|
||||||
</Page.Title>
|
</Page.Title>
|
||||||
</Page.HeaderContent>
|
</Page.HeaderContent>
|
||||||
|
<Page.Action>
|
||||||
|
<RefreshPageData />
|
||||||
|
</Page.Action>
|
||||||
</Page.Header>
|
</Page.Header>
|
||||||
<Page.Body>
|
<Page.Body>
|
||||||
{#snippet child()}
|
{#snippet child()}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { LessonsPage } from '$features/course/pages';
|
import { LessonsPage } from '$features/course/pages';
|
||||||
import { Button } from '@cio/ui/base/button';
|
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 * as Page from '@cio/ui/base/page';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { contentCreateStoreUtils, contentEditingStore } from '$features/course/components/content/store';
|
import { contentCreateStoreUtils, contentEditingStore } from '$features/course/components/content/store';
|
||||||
import { courseApi } from '$features/course/api';
|
import { courseApi } from '$features/course/api';
|
||||||
|
import { profile } from '$lib/utils/store/user';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@
|
|||||||
>{$t('course.navItem.lessons.add_content')}</Button
|
>{$t('course.navItem.lessons.add_content')}</Button
|
||||||
>
|
>
|
||||||
</RoleBasedSecurity>
|
</RoleBasedSecurity>
|
||||||
|
<RefreshPageData onRefresh={() => courseApi.refreshCourse(data.courseId, $profile.id)} />
|
||||||
</div>
|
</div>
|
||||||
</Page.Action>
|
</Page.Action>
|
||||||
</Page.Header>
|
</Page.Header>
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!data.lesson) return;
|
if (!data.lesson) return;
|
||||||
const lesson = data.lesson;
|
const lesson = data.lesson;
|
||||||
|
|
||||||
if (lessonApi.lesson?.id === lesson.id) return;
|
if (lessonApi.lesson?.id === lesson.id) return;
|
||||||
|
|
||||||
// Set lesson data
|
// Set lesson data
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||||
import { courseApi } from '$features/course/api';
|
import { courseApi } from '$features/course/api';
|
||||||
import { generateMarksCSV, generateMarksPDF } from '$features/course/utils/marks-utils';
|
import { generateMarksCSV, generateMarksPDF } from '$features/course/utils/marks-utils';
|
||||||
|
import { RefreshPageData } from '$features/ui';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -97,6 +98,7 @@
|
|||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
</RoleBasedSecurity>
|
</RoleBasedSecurity>
|
||||||
|
<RefreshPageData />
|
||||||
</div>
|
</div>
|
||||||
</Page.Action>
|
</Page.Action>
|
||||||
</Page.Header>
|
</Page.Header>
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
<script lang="ts">
|
<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 { page } from '$app/state';
|
||||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||||
import { Button } from '@cio/ui/base/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 { t } from '$lib/utils/functions/translations';
|
||||||
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
|
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
|
||||||
import * as Page from '@cio/ui/base/page';
|
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();
|
let { data = $bindable(), children } = $props();
|
||||||
|
|
||||||
@@ -14,17 +17,20 @@
|
|||||||
let backUrl = $derived(page.url.searchParams.get('back'));
|
let backUrl = $derived(page.url.searchParams.get('back'));
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
const url = page.url.href + '?add=true';
|
goto(resolve(`${page.url.pathname}?add=true`, {}));
|
||||||
goto(url);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBackNavigation = () => {
|
const handleBackNavigation = () => {
|
||||||
if (backUrl) {
|
if (backUrl) {
|
||||||
goto(backUrl);
|
goto(resolve(backUrl, {}));
|
||||||
} else {
|
} 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>
|
</script>
|
||||||
|
|
||||||
<Page.Root class="mx-auto w-[90%] md:max-w-3xl">
|
<Page.Root class="mx-auto w-[90%] md:max-w-3xl">
|
||||||
@@ -42,13 +48,16 @@
|
|||||||
</Page.Title>
|
</Page.Title>
|
||||||
</Page.HeaderContent>
|
</Page.HeaderContent>
|
||||||
<Page.Action>
|
<Page.Action>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
{#if !data.personId}
|
{#if !data.personId}
|
||||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||||
<Button class="mr-2" onclick={handleClick}>
|
<Button onclick={handleClick}>
|
||||||
{$t('course.navItem.people.add')}
|
{$t('course.navItem.people.add')}
|
||||||
</Button>
|
</Button>
|
||||||
</RoleBasedSecurity>
|
</RoleBasedSecurity>
|
||||||
{/if}
|
{/if}
|
||||||
|
<RefreshPageData onRefresh={refreshPeoplePage} />
|
||||||
|
</div>
|
||||||
</Page.Action>
|
</Page.Action>
|
||||||
</Page.Header>
|
</Page.Header>
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,13 @@
|
|||||||
import * as Page from '@cio/ui/base/page';
|
import * as Page from '@cio/ui/base/page';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { Button } from '@cio/ui/base/button';
|
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 settingsComponent: CourseSettingsPage | null = $state(null);
|
||||||
let isSaving = $state(false);
|
let isSaving = $state(false);
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
isSaving = true;
|
isSaving = true;
|
||||||
@@ -25,9 +29,12 @@
|
|||||||
</Page.Title>
|
</Page.Title>
|
||||||
</Page.HeaderContent>
|
</Page.HeaderContent>
|
||||||
<Page.Action>
|
<Page.Action>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
<Button variant="secondary" loading={isSaving} onclick={handleSave}>
|
<Button variant="secondary" loading={isSaving} onclick={handleSave}>
|
||||||
{$t('course.navItem.settings.save')}
|
{$t('course.navItem.settings.save')}
|
||||||
</Button>
|
</Button>
|
||||||
|
<RefreshPageData disabled={isSaving} onRefresh={() => courseApi.refreshCourse(data.courseId, $profile.id)} />
|
||||||
|
</div>
|
||||||
</Page.Action>
|
</Page.Action>
|
||||||
</Page.Header>
|
</Page.Header>
|
||||||
<Page.Body>
|
<Page.Body>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { SubmissionsPage } from '$features/course/pages';
|
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 * as Page from '@cio/ui/base/page';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
|
|
||||||
@@ -15,6 +15,9 @@
|
|||||||
{$t('course.navItem.submissions.title')}
|
{$t('course.navItem.submissions.title')}
|
||||||
</Page.Title>
|
</Page.Title>
|
||||||
</Page.HeaderContent>
|
</Page.HeaderContent>
|
||||||
|
<Page.Action>
|
||||||
|
<RefreshPageData />
|
||||||
|
</Page.Action>
|
||||||
</Page.Header>
|
</Page.Header>
|
||||||
|
|
||||||
<Page.Body>
|
<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>
|
<script>
|
||||||
import { AutomationPage } from '$features/automation/pages';
|
import { ApiAutomationPage } from '$features/automation/pages';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import * as Page from '@cio/ui/base/page';
|
import * as Page from '@cio/ui/base/page';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{t.get('automation.title')} - ClassroomIO</title>
|
<title>{t.get('automation.tabs.api')} - ClassroomIO</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<Page.Header>
|
<Page.Header>
|
||||||
<Page.HeaderContent>
|
<Page.HeaderContent>
|
||||||
<Page.Title>{$t('automation.title')}</Page.Title>
|
<Page.Title>{$t('automation.tabs.api')}</Page.Title>
|
||||||
<Page.Subtitle>{$t('automation.subtitle')}</Page.Subtitle>
|
<Page.Subtitle>{$t('automation.coming_soon')}</Page.Subtitle>
|
||||||
</Page.HeaderContent>
|
</Page.HeaderContent>
|
||||||
</Page.Header>
|
</Page.Header>
|
||||||
|
|
||||||
<Page.Body>
|
<Page.Body>
|
||||||
{#snippet child()}
|
{#snippet child()}
|
||||||
<AutomationPage />
|
<ApiAutomationPage />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Page.Body>
|
</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 searchValue = $state('');
|
||||||
let selectedId: string = $state('0');
|
let selectedId: string = $state('0');
|
||||||
|
let selectedOrder = $state<'asc' | 'desc'>('desc');
|
||||||
let selectedTags = $state<string[]>([]);
|
let selectedTags = $state<string[]>([]);
|
||||||
let hasInitializedFilters = $state(false);
|
let hasInitializedFilters = $state(false);
|
||||||
let isFiltering = $state(false);
|
let isFiltering = $state(false);
|
||||||
|
|
||||||
const validSortValues = new Set(COURSE_SORT_OPTIONS.map((option) => option.value));
|
const validSortValues = new Set(COURSE_SORT_OPTIONS.map((option) => option.value));
|
||||||
|
const validSortOrders = new Set(['asc', 'desc']);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (data.courses) {
|
if (data.courses) {
|
||||||
@@ -37,6 +39,27 @@
|
|||||||
return '0';
|
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() {
|
function updateFiltersUrl() {
|
||||||
if (!browser || !hasInitializedFilters) {
|
if (!browser || !hasInitializedFilters) {
|
||||||
return;
|
return;
|
||||||
@@ -56,6 +79,12 @@
|
|||||||
nextUrl.searchParams.delete('sort');
|
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}`;
|
const nextPath = `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`;
|
||||||
window.history.replaceState(window.history.state, '', nextPath);
|
window.history.replaceState(window.history.state, '', nextPath);
|
||||||
}
|
}
|
||||||
@@ -65,7 +94,8 @@
|
|||||||
return;
|
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();
|
updateFiltersUrl();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,6 +124,7 @@
|
|||||||
|
|
||||||
async function clearFilters() {
|
async function clearFilters() {
|
||||||
selectedId = '0';
|
selectedId = '0';
|
||||||
|
selectedOrder = 'desc';
|
||||||
|
|
||||||
if (selectedTags.length === 0) {
|
if (selectedTags.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -111,15 +142,27 @@
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sortedCourses = [...filteredCourses];
|
||||||
|
|
||||||
if (selectedId === '0') {
|
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') {
|
} 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') {
|
} 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(() => {
|
onMount(() => {
|
||||||
@@ -129,9 +172,32 @@
|
|||||||
$courseMetaDeta.view = courseView;
|
$courseMetaDeta.view = courseView;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortFromUrl = normalizeSortValue(new URLSearchParams(window.location.search).get('sort'));
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
const sortFromStorage = normalizeSortValue(localStorage.getItem('classroomio_filter_course_key'));
|
const legacySortFromUrl = getLegacySortState(searchParams.get('sort'));
|
||||||
selectedId = sortFromUrl !== '0' ? sortFromUrl : sortFromStorage;
|
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;
|
hasInitializedFilters = true;
|
||||||
updateFiltersUrl();
|
updateFiltersUrl();
|
||||||
});
|
});
|
||||||
@@ -156,6 +222,7 @@
|
|||||||
{#snippet filterControls()}
|
{#snippet filterControls()}
|
||||||
<CourseFilterPopover
|
<CourseFilterPopover
|
||||||
bind:selectedId
|
bind:selectedId
|
||||||
|
bind:selectedOrder
|
||||||
{selectedTags}
|
{selectedTags}
|
||||||
tagGroups={data.tagGroups}
|
tagGroups={data.tagGroups}
|
||||||
{isFiltering}
|
{isFiltering}
|
||||||
|
|||||||
+22
-104
@@ -4,101 +4,19 @@ import adapterAuto from '@sveltejs/adapter-auto';
|
|||||||
import adapterNode from '@sveltejs/adapter-node';
|
import adapterNode from '@sveltejs/adapter-node';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
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 isSelfHosted = process.env.PUBLIC_IS_SELFHOSTED === 'true';
|
||||||
const API_ORIGIN = process.env.PUBLIC_SERVER_URL;
|
|
||||||
const IS_CLOUDFLARE = process.env.CI_ENVIRONMENT === 'cloudflare';
|
const IS_CLOUDFLARE = process.env.CI_ENVIRONMENT === 'cloudflare';
|
||||||
|
|
||||||
const adapterCloudflare = IS_CLOUDFLARE ? (await import('@sveltejs/adapter-cloudflare')).default : null;
|
const adapterCloudflare = IS_CLOUDFLARE ? (await import('@sveltejs/adapter-cloudflare')).default : null;
|
||||||
|
const csp = getCspDomains(isSelfHosted, process.env.PUBLIC_SERVER_URL);
|
||||||
/**
|
|
||||||
* Parse comma-separated domain list from env.
|
|
||||||
* Normalizes to https:// URLs. Returns null when env var is unset (use defaults).
|
|
||||||
*/
|
|
||||||
function parseDomainsFromEnv(envVar) {
|
|
||||||
const value = process.env[envVar];
|
|
||||||
if (!value || typeof value !== 'string') return null;
|
|
||||||
return value
|
|
||||||
.split(',')
|
|
||||||
.map((d) => d.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((d) => (d.startsWith('http://') || d.startsWith('https://') ? d : `https://${d}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
const ALLOWED_EXTERNAL_DOMAINS = parseDomainsFromEnv('ALLOWED_EXTERNAL_DOMAINS');
|
|
||||||
const ALLOWED_SCRIPT_SRC = parseDomainsFromEnv('CSP_SCRIPT_SRC_DOMAINS');
|
|
||||||
const ALLOWED_STYLE_SRC = parseDomainsFromEnv('CSP_STYLE_SRC_DOMAINS');
|
|
||||||
const ALLOWED_CONNECT_SRC = parseDomainsFromEnv('CSP_CONNECT_SRC_DOMAINS');
|
|
||||||
const ALLOWED_FRAME_SRC = parseDomainsFromEnv('CSP_FRAME_SRC_DOMAINS');
|
|
||||||
const ALLOWED_FONT_SRC = parseDomainsFromEnv('CSP_FONT_SRC_DOMAINS');
|
|
||||||
|
|
||||||
const defaultScriptSrcDomains = [
|
|
||||||
'https://assets.cdn.clsrio.com',
|
|
||||||
'https://cdnjs.cloudflare.com',
|
|
||||||
'https://*.posthog.com',
|
|
||||||
'https://*.senja.io',
|
|
||||||
'https://umami.hz.oncws.com',
|
|
||||||
'https://www.youtube.com',
|
|
||||||
'https://youtube.com',
|
|
||||||
'https://google.com'
|
|
||||||
];
|
|
||||||
const defaultStyleSrcDomains = [
|
|
||||||
'https://cdn.plyr.io',
|
|
||||||
'https://unpkg.com/[email protected]/dist/katex.min.css',
|
|
||||||
'https://assets.cdn.clsrio.com/eqneditor_1.css',
|
|
||||||
'https://fonts.googleapis.com'
|
|
||||||
];
|
|
||||||
const defaultConnectSrcDomains = [
|
|
||||||
'https://*.classroomio.com',
|
|
||||||
'https://classroomio.com',
|
|
||||||
'https://app.classroomio.com',
|
|
||||||
'https://api.classroomio.com',
|
|
||||||
'https://pgrest.classroomio.com',
|
|
||||||
'https://play.classroomio.com',
|
|
||||||
'wss://*.classroomio.com',
|
|
||||||
'https://assets.cdn.clsrio.com',
|
|
||||||
'https://cdn.plyr.io',
|
|
||||||
'https://*.posthog.com',
|
|
||||||
'https://umami.hz.oncws.com',
|
|
||||||
'https://*.r2.cloudflarestorage.com',
|
|
||||||
'https://*.senja.io',
|
|
||||||
'https://*.ytimg.com',
|
|
||||||
'https://noembed.com'
|
|
||||||
];
|
|
||||||
const defaultFrameSrcDomains = [
|
|
||||||
'https://www.youtube.com',
|
|
||||||
'https://youtube.com',
|
|
||||||
'https://www.youtube-nocookie.com',
|
|
||||||
'https://www.google.com',
|
|
||||||
'https://google.com'
|
|
||||||
];
|
|
||||||
const defaultFontSrcDomains = ['https://fonts.gstatic.com', 'https://cdn.plyr.io'];
|
|
||||||
|
|
||||||
// Self-hosted: use ONLY env vars, no defaults (strict CISO allowlists, data residency).
|
|
||||||
// SaaS: use defaults when env vars are unset (backward compatible).
|
|
||||||
const scriptSrcDomains = useNodeAdapter
|
|
||||||
? (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_SCRIPT_SRC ?? [])
|
|
||||||
: (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_SCRIPT_SRC ?? defaultScriptSrcDomains);
|
|
||||||
const styleSrcDomains = useNodeAdapter
|
|
||||||
? (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_STYLE_SRC ?? [])
|
|
||||||
: (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_STYLE_SRC ?? defaultStyleSrcDomains);
|
|
||||||
const connectSrcDomains = useNodeAdapter
|
|
||||||
? (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_CONNECT_SRC ?? [])
|
|
||||||
: (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_CONNECT_SRC ?? defaultConnectSrcDomains);
|
|
||||||
const frameSrcDomains = useNodeAdapter
|
|
||||||
? (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_FRAME_SRC ?? [])
|
|
||||||
: (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_FRAME_SRC ?? defaultFrameSrcDomains);
|
|
||||||
const fontSrcDomains = useNodeAdapter
|
|
||||||
? (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_FONT_SRC ?? [])
|
|
||||||
: (ALLOWED_EXTERNAL_DOMAINS ?? ALLOWED_FONT_SRC ?? defaultFontSrcDomains);
|
|
||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
|
|
||||||
// for more information about preprocessors
|
|
||||||
preprocess: [vitePreprocess({})],
|
preprocess: [vitePreprocess({})],
|
||||||
kit: {
|
kit: {
|
||||||
adapter: IS_CLOUDFLARE ? adapterCloudflare() : useNodeAdapter ? adapterNode() : adapterAuto(),
|
adapter: IS_CLOUDFLARE ? adapterCloudflare() : isSelfHosted ? adapterNode() : adapterAuto(),
|
||||||
alias: {
|
alias: {
|
||||||
$lib: path.resolve('./src/lib'),
|
$lib: path.resolve('./src/lib'),
|
||||||
$features: path.resolve('./src/lib/features'),
|
$features: path.resolve('./src/lib/features'),
|
||||||
@@ -117,20 +35,20 @@ const config = {
|
|||||||
mode: 'auto',
|
mode: 'auto',
|
||||||
directives: {
|
directives: {
|
||||||
'default-src': ['self'],
|
'default-src': ['self'],
|
||||||
'script-src': ['self', ...scriptSrcDomains, 'unsafe-hashes', 'unsafe-eval'],
|
'script-src': ['self', ...csp.scriptSrc, 'unsafe-hashes', 'unsafe-eval'],
|
||||||
'style-src': ['self', 'unsafe-inline', ...styleSrcDomains],
|
'style-src': ['self', 'unsafe-inline', ...csp.styleSrc],
|
||||||
'style-src-elem': ['self', 'unsafe-inline', ...styleSrcDomains],
|
'style-src-elem': ['self', 'unsafe-inline', ...csp.styleSrc],
|
||||||
'font-src': ['self', ...fontSrcDomains],
|
'font-src': ['self', ...csp.fontSrc],
|
||||||
'img-src': ['self', 'data:', 'https:', 'blob:', 'http://localhost:9000'],
|
'img-src': ['self', 'data:', ...csp.mediaSrc, 'blob:', 'http://localhost:9000'],
|
||||||
'media-src': ['self', 'https:', 'data:', 'blob:', 'http://localhost:9000'],
|
'media-src': ['self', ...csp.mediaSrc, 'data:', 'blob:', 'http://localhost:9000'],
|
||||||
'frame-src': ['self', ...frameSrcDomains],
|
'frame-src': ['self', ...csp.frameSrc],
|
||||||
'connect-src': [
|
'connect-src': [
|
||||||
'self',
|
'self',
|
||||||
'blob:',
|
'blob:',
|
||||||
'http://localhost:3002',
|
'http://localhost:3002',
|
||||||
'http://localhost:9000',
|
'http://localhost:9000',
|
||||||
...(API_ORIGIN ? [API_ORIGIN] : []),
|
...(csp.apiOrigin ? [csp.apiOrigin] : []),
|
||||||
...connectSrcDomains
|
...csp.connectSrc
|
||||||
],
|
],
|
||||||
'worker-src': ['self', 'blob:'],
|
'worker-src': ['self', 'blob:'],
|
||||||
'object-src': ['none'],
|
'object-src': ['none'],
|
||||||
@@ -141,20 +59,20 @@ const config = {
|
|||||||
},
|
},
|
||||||
reportOnly: {
|
reportOnly: {
|
||||||
'default-src': ['self'],
|
'default-src': ['self'],
|
||||||
'script-src': ['self', ...scriptSrcDomains, 'unsafe-hashes', 'unsafe-eval'],
|
'script-src': ['self', ...csp.scriptSrc, 'unsafe-hashes', 'unsafe-eval'],
|
||||||
'style-src': ['self', 'unsafe-inline', ...styleSrcDomains],
|
'style-src': ['self', 'unsafe-inline', ...csp.styleSrc],
|
||||||
'style-src-elem': ['self', 'unsafe-inline', ...styleSrcDomains],
|
'style-src-elem': ['self', 'unsafe-inline', ...csp.styleSrc],
|
||||||
'font-src': ['self', ...fontSrcDomains],
|
'font-src': ['self', ...csp.fontSrc],
|
||||||
'img-src': ['self', 'data:', 'https:', 'blob:', 'http://localhost:9000'],
|
'img-src': ['self', 'data:', ...csp.mediaSrc, 'blob:', 'http://localhost:9000'],
|
||||||
'media-src': ['self', 'https:', 'data:', 'blob:', 'http://localhost:9000'],
|
'media-src': ['self', ...csp.mediaSrc, 'data:', 'blob:', 'http://localhost:9000'],
|
||||||
'frame-src': ['self', ...frameSrcDomains],
|
'frame-src': ['self', ...csp.frameSrc],
|
||||||
'connect-src': [
|
'connect-src': [
|
||||||
'self',
|
'self',
|
||||||
'blob:',
|
'blob:',
|
||||||
'http://localhost:3002',
|
'http://localhost:3002',
|
||||||
'http://localhost:9000',
|
'http://localhost:9000',
|
||||||
...(API_ORIGIN ? [API_ORIGIN] : []),
|
...(csp.apiOrigin ? [csp.apiOrigin] : []),
|
||||||
...connectSrcDomains
|
...csp.connectSrc
|
||||||
],
|
],
|
||||||
'worker-src': ['self', 'blob:'],
|
'worker-src': ['self', 'blob:'],
|
||||||
'object-src': ['none'],
|
'object-src': ['none'],
|
||||||
|
|||||||
+209
-52
@@ -132,6 +132,7 @@ Current tools:
|
|||||||
|
|
||||||
- `list_org_courses`
|
- `list_org_courses`
|
||||||
- `get_course_structure`
|
- `get_course_structure`
|
||||||
|
- `reorder_course_content`
|
||||||
- `update_course_landing_page`
|
- `update_course_landing_page`
|
||||||
- `create_course_draft`
|
- `create_course_draft`
|
||||||
- `create_course_draft_from_course`
|
- `create_course_draft_from_course`
|
||||||
@@ -172,7 +173,42 @@ Drafts support:
|
|||||||
- safer updates to existing courses
|
- safer updates to existing courses
|
||||||
- auditability of AI-generated structure
|
- 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:
|
Typical flow:
|
||||||
|
|
||||||
@@ -195,26 +231,20 @@ Publish returns:
|
|||||||
- use an explicit `bannerImageUrl`
|
- use an explicit `bannerImageUrl`
|
||||||
- generate a random Unsplash-based banner from the course title or a custom query
|
- 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
|
Expected tool sequence:
|
||||||
2. `get_course_structure`
|
|
||||||
3. `create_course_draft_from_course`
|
|
||||||
4. `update_course_draft`
|
|
||||||
5. `publish_course_draft_to_existing_course`
|
|
||||||
|
|
||||||
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
|
What the landing-page tool can update:
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
- title
|
- title
|
||||||
- short description
|
- short description
|
||||||
@@ -224,33 +254,167 @@ This tool can update:
|
|||||||
- pricing fields
|
- pricing fields
|
||||||
- instructor section
|
- instructor section
|
||||||
- reviews
|
- reviews
|
||||||
- the landing-page image
|
- landing page image
|
||||||
|
|
||||||
For images, the tool supports either:
|
The tool can either:
|
||||||
|
|
||||||
- `imageUrl` for an explicit cover image
|
- use an explicit `imageUrl`
|
||||||
- `generateImage` and `imageQuery` for a random Unsplash-derived image
|
- 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
|
Expected tool sequence:
|
||||||
- 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
|
|
||||||
|
|
||||||
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
|
Important:
|
||||||
- another agent session may have changed the course
|
|
||||||
- reusing an old published draft can overwrite newer live changes
|
|
||||||
|
|
||||||
## 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
|
### Flow 4: Tag a draft before publish
|
||||||
exercise tools instead of creating a whole new draft:
|
|
||||||
|
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`
|
- `list_course_exercises`
|
||||||
- `get_course_exercise`
|
- `get_course_exercise`
|
||||||
@@ -258,8 +422,6 @@ exercise tools instead of creating a whole new draft:
|
|||||||
- `create_course_exercise_from_template`
|
- `create_course_exercise_from_template`
|
||||||
- `update_course_exercise`
|
- `update_course_exercise`
|
||||||
|
|
||||||
Use a draft only when the agent is restructuring broader course content.
|
|
||||||
|
|
||||||
Supported `questionTypeId` values for exercise payloads:
|
Supported `questionTypeId` values for exercise payloads:
|
||||||
|
|
||||||
- `1` `RADIO` - Single answer
|
- `1` `RADIO` - Single answer
|
||||||
@@ -275,29 +437,24 @@ Supported `questionTypeId` values for exercise payloads:
|
|||||||
- `11` `HOTSPOT` - Hotspot
|
- `11` `HOTSPOT` - Hotspot
|
||||||
- `12` `LINK` - Links
|
- `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`
|
## Recommended Agent Behavior
|
||||||
- set or revise tags before publish
|
|
||||||
- `tag_courses`
|
|
||||||
- tag one or more live courses
|
|
||||||
|
|
||||||
The live-course tagging tool supports merge-or-replace behavior. Use merge when the user wants to
|
For small edits, an agent can still read the course structure and modify only the affected items in the draft.
|
||||||
add tags without removing existing ones.
|
|
||||||
|
|
||||||
## 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
|
That keeps major AI-assisted edits reviewable and explicit.
|
||||||
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.
|
|
||||||
|
|
||||||
<Callout title="Operational Rule">
|
<Callout title="Operational Rule">
|
||||||
Keep published drafts for history and auditability. Do not treat omission from a draft as an
|
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
|
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:
|
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` |
|
| `PUBLIC_IS_SELFHOSTED` | Self-hosted mode flag | `true` |
|
||||||
| `PRIVATE_SERVER_URL` | Internal API URL for SSR (Docker network) | `http://api:3081` |
|
| `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` |
|
| `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">
|
<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>
|
</Callout>
|
||||||
|
|
||||||
#### Optional — Email
|
#### Optional — Email
|
||||||
@@ -229,7 +242,7 @@ PRIVATE_SERVER_URL="http://${{api.RAILWAY_PRIVATE_DOMAIN}}:8080"
|
|||||||
PUBLIC_SERVER_URL="https://api.yourdomain.com"
|
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">
|
<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.
|
`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 find . -name 'tsconfig.tsbuildinfo' -delete
|
||||||
RUN pnpm -r --filter @cio/api... build
|
RUN pnpm -r --filter @cio/api... build
|
||||||
|
|
||||||
|
COPY docker/entrypoint-api.sh /app/entrypoint.sh
|
||||||
|
RUN chmod +x /app/entrypoint.sh
|
||||||
|
|
||||||
EXPOSE 3081
|
EXPOSE 3081
|
||||||
|
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
PORT=3081
|
PORT=3081
|
||||||
|
|
||||||
CMD ["pnpm", "--filter", "@cio/api", "start"]
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
|
|||||||
@@ -5,13 +5,6 @@ WORKDIR /app
|
|||||||
ENV CI=true
|
ENV CI=true
|
||||||
|
|
||||||
ARG PUBLIC_IS_SELFHOSTED=true
|
ARG PUBLIC_IS_SELFHOSTED=true
|
||||||
ARG PUBLIC_SERVER_URL
|
|
||||||
ARG ALLOWED_EXTERNAL_DOMAINS
|
|
||||||
ARG CSP_SCRIPT_SRC_DOMAINS
|
|
||||||
ARG CSP_STYLE_SRC_DOMAINS
|
|
||||||
ARG CSP_CONNECT_SRC_DOMAINS
|
|
||||||
ARG CSP_FRAME_SRC_DOMAINS
|
|
||||||
ARG CSP_FONT_SRC_DOMAINS
|
|
||||||
|
|
||||||
RUN corepack enable && corepack prepare [email protected] --activate
|
RUN corepack enable && corepack prepare [email protected] --activate
|
||||||
|
|
||||||
@@ -19,17 +12,8 @@ COPY . .
|
|||||||
|
|
||||||
RUN pnpm install --frozen-lockfile --ignore-scripts --filter @cio/dashboard...
|
RUN pnpm install --frozen-lockfile --ignore-scripts --filter @cio/dashboard...
|
||||||
RUN find . -name 'tsconfig.tsbuildinfo' -delete
|
RUN find . -name 'tsconfig.tsbuildinfo' -delete
|
||||||
RUN echo "Dashboard build PUBLIC_IS_SELFHOSTED=${PUBLIC_IS_SELFHOSTED}" && \
|
RUN NODE_OPTIONS="--max-old-space-size=3072" \
|
||||||
echo "Dashboard build PUBLIC_SERVER_URL=${PUBLIC_SERVER_URL}" && \
|
|
||||||
NODE_OPTIONS="--max-old-space-size=3072" \
|
|
||||||
PUBLIC_IS_SELFHOSTED="${PUBLIC_IS_SELFHOSTED}" \
|
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
|
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)
|
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:
|
profiles:
|
||||||
- minio
|
- 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:
|
api:
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
@@ -101,8 +84,6 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
db-init:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: 3081
|
PORT: 3081
|
||||||
@@ -137,6 +118,7 @@ services:
|
|||||||
LICENSE_KEY: ${LICENSE_KEY:-}
|
LICENSE_KEY: ${LICENSE_KEY:-}
|
||||||
UNSPLASH_API_KEY: ${UNSPLASH_API_KEY:-}
|
UNSPLASH_API_KEY: ${UNSPLASH_API_KEY:-}
|
||||||
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-local-dev-only-secret-change-this}
|
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-local-dev-only-secret-change-this}
|
||||||
|
SKIP_DB_SETUP: ${SKIP_DB_SETUP:-}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
dashboard:
|
dashboard:
|
||||||
@@ -145,13 +127,6 @@ services:
|
|||||||
dockerfile: docker/Dockerfile.dashboard
|
dockerfile: docker/Dockerfile.dashboard
|
||||||
args:
|
args:
|
||||||
PUBLIC_IS_SELFHOSTED: ${PUBLIC_IS_SELFHOSTED:-true}
|
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
|
container_name: cio-dashboard
|
||||||
ports:
|
ports:
|
||||||
- "3082:3082"
|
- "3082:3082"
|
||||||
@@ -169,6 +144,13 @@ services:
|
|||||||
PRIVATE_APP_HOST: ${PRIVATE_APP_HOST:-localhost}
|
PRIVATE_APP_HOST: ${PRIVATE_APP_HOST:-localhost}
|
||||||
PRIVATE_APP_SUBDOMAINS: ${PRIVATE_APP_SUBDOMAINS:-app}
|
PRIVATE_APP_SUBDOMAINS: ${PRIVATE_APP_SUBDOMAINS:-app}
|
||||||
AUTH_BEARER_TOKEN: ${AUTH_BEARER_TOKEN:-}
|
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
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -42,13 +42,6 @@ echo "Dashboard build arg PUBLIC_IS_SELFHOSTED=${PUBLIC_IS_SELFHOSTED}"
|
|||||||
docker build \
|
docker build \
|
||||||
-f docker/Dockerfile.dashboard \
|
-f docker/Dockerfile.dashboard \
|
||||||
--build-arg PUBLIC_IS_SELFHOSTED=${PUBLIC_IS_SELFHOSTED} \
|
--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:${VERSION} \
|
||||||
-t ${DOCKERHUB_USERNAME}/dashboard:latest \
|
-t ${DOCKERHUB_USERNAME}/dashboard:latest \
|
||||||
.
|
.
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ The GitHub Actions workflow builds for both `linux/amd64` and `linux/arm64` plat
|
|||||||
|
|
||||||
### Build failures
|
### Build failures
|
||||||
- Check Dockerfile syntax
|
- 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`
|
- Check available disk space: `docker system df`
|
||||||
|
|
||||||
### Clean up old images
|
### Clean up old images
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ All Docker services read from a single root `.env` file. See [`.env.example`](..
|
|||||||
|
|
||||||
Key points:
|
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.
|
- **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-generated:** `AUTH_BEARER_TOKEN`, `PRIVATE_SERVER_KEY` (by `./run-docker-full-stack.sh`).
|
||||||
- **Auto-configured:** All `MINIO_*` / `OBJECT_STORAGE_*` vars (by the startup script).
|
- **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": [
|
"onlyBuiltDependencies": [
|
||||||
"esbuild",
|
"esbuild",
|
||||||
"@tailwindcss/oxide"
|
"@tailwindcss/oxide"
|
||||||
]
|
],
|
||||||
|
"patchedDependencies": {
|
||||||
|
"[email protected]": "patches/[email protected]"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"packageManager": "[email protected]+sha512.c9fc7236e92adf5c8af42fd5bf1612df99c2ceb62f27047032f4720b33f8eacdde311865e91c411f2774f618d82f320808ecb51718bfa82c060c4ba7c76a32b8"
|
"packageManager": "[email protected]+sha512.c9fc7236e92adf5c8af42fd5bf1612df99c2ceb62f27047032f4720b33f8eacdde311865e91c411f2774f618d82f320808ecb51718bfa82c060c4ba7c76a32b8"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as schema from '@db/schema';
|
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 { db } from '@db/drizzle';
|
||||||
import type { TNewOrganizationApiKey, TOrganizationApiKey, TOrganizationApiKeyType } from '@db/types';
|
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 (
|
export const getOrganizationApiKeyById = async (
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
keyId: 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