feat: implement automation feature
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import { Context, Next } from 'hono';
|
||||
import type { TOrganizationApiKeyScope } from '@cio/utils/validation/organization';
|
||||
|
||||
import { ErrorCodes } from '@api/utils/errors';
|
||||
import { organizationApiKeyHasScopes } from '@api/services/organization/automation-key';
|
||||
import { getCourseOrganizationId } from '@cio/db/queries/tag';
|
||||
import { courseMemberMiddleware } from './course-member';
|
||||
|
||||
export const courseMemberOrAutomationKeyMiddleware =
|
||||
(requiredScopes: readonly TOrganizationApiKeyScope[] = []) =>
|
||||
async (c: Context, next: Next) => {
|
||||
try {
|
||||
const automationKey = c.get('automationKey');
|
||||
|
||||
if (!automationKey) {
|
||||
return courseMemberMiddleware(c, next);
|
||||
}
|
||||
|
||||
if (!organizationApiKeyHasScopes(automationKey.scopes ?? [], requiredScopes)) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Automation key is missing required scopes',
|
||||
code: ErrorCodes.FORBIDDEN
|
||||
},
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
const courseId = c.req.param('courseId') || c.req.query('courseId');
|
||||
if (!courseId) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Course ID is required',
|
||||
code: 'COURSE_ID_REQUIRED'
|
||||
},
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const courseOrgId = await getCourseOrganizationId(courseId);
|
||||
if (!courseOrgId) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Course not found',
|
||||
code: ErrorCodes.COURSE_NOT_FOUND
|
||||
},
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
if (courseOrgId !== automationKey.organizationId) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Invalid permissions',
|
||||
code: ErrorCodes.UNAUTHORIZED
|
||||
},
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
c.set('orgId', automationKey.organizationId);
|
||||
c.set('actorId', automationKey.createdByProfileId);
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
console.error('Error in courseMemberOrAutomationKeyMiddleware:', error);
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to verify course access',
|
||||
code: 'COURSE_MEMBER_CHECK_FAILED'
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Context, Next } from 'hono';
|
||||
import type { TOrganizationApiKeyScope } from '@cio/utils/validation/organization';
|
||||
|
||||
import { ErrorCodes } from '@api/utils/errors';
|
||||
import { organizationApiKeyHasScopes } from '@api/services/organization/automation-key';
|
||||
import { getCourseOrganizationId } from '@cio/db/queries/tag';
|
||||
import { courseTeamMemberMiddleware } from './course-team-member';
|
||||
|
||||
export const courseTeamMemberOrAutomationKeyMiddleware =
|
||||
(requiredScopes: readonly TOrganizationApiKeyScope[] = []) =>
|
||||
async (c: Context, next: Next) => {
|
||||
try {
|
||||
const automationKey = c.get('automationKey');
|
||||
|
||||
if (!automationKey) {
|
||||
return courseTeamMemberMiddleware(c, next);
|
||||
}
|
||||
|
||||
if (!organizationApiKeyHasScopes(automationKey.scopes ?? [], requiredScopes)) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Automation key is missing required scopes',
|
||||
code: ErrorCodes.FORBIDDEN
|
||||
},
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
const courseId = c.req.param('courseId') || c.req.query('courseId');
|
||||
if (!courseId) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Course ID is required',
|
||||
code: 'COURSE_ID_REQUIRED'
|
||||
},
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const courseOrgId = await getCourseOrganizationId(courseId);
|
||||
if (!courseOrgId) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Course not found',
|
||||
code: ErrorCodes.COURSE_NOT_FOUND
|
||||
},
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
if (courseOrgId !== automationKey.organizationId) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Invalid permissions',
|
||||
code: ErrorCodes.UNAUTHORIZED
|
||||
},
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
c.set('orgId', automationKey.organizationId);
|
||||
c.set('actorId', automationKey.createdByProfileId);
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
console.error('Error in courseTeamMemberOrAutomationKeyMiddleware:', error);
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to verify course team access',
|
||||
code: 'COURSE_TEAM_MEMBER_CHECK_FAILED'
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Context, Next } from 'hono';
|
||||
import type { TOrganizationApiKeyScope } from '@cio/utils/validation/organization';
|
||||
|
||||
import { ErrorCodes } from '@api/utils/errors';
|
||||
import { organizationApiKeyHasScopes } from '@api/services/organization/automation-key';
|
||||
import { orgMemberMiddleware } from './org-member';
|
||||
|
||||
export const orgMemberOrAutomationKeyMiddleware =
|
||||
(requiredScopes: readonly TOrganizationApiKeyScope[] = []) =>
|
||||
async (c: Context, next: Next) => {
|
||||
try {
|
||||
const automationKey = c.get('automationKey');
|
||||
|
||||
if (!automationKey) {
|
||||
return orgMemberMiddleware(c, next);
|
||||
}
|
||||
|
||||
if (!organizationApiKeyHasScopes(automationKey.scopes ?? [], requiredScopes)) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Automation key is missing required scopes',
|
||||
code: ErrorCodes.FORBIDDEN
|
||||
},
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
c.set('orgId', automationKey.organizationId);
|
||||
c.set('userRole', null);
|
||||
c.set('actorId', automationKey.createdByProfileId);
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
console.error('Error in orgMemberOrAutomationKeyMiddleware:', error);
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to verify organization membership',
|
||||
code: 'ORG_MEMBER_CHECK_FAILED'
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,13 +1,47 @@
|
||||
import { ZCourseContentDelete, ZCourseContentUpdate } from '@cio/utils/validation/course';
|
||||
import { deleteCourseContent, updateCourseContent } from '@api/services/course/content';
|
||||
import { ZCourseContentDelete, ZCourseContentReorder, ZCourseContentUpdate } from '@cio/utils/validation/course';
|
||||
import { deleteCourseContent, reorderCourseContent, updateCourseContent } from '@api/services/course/content';
|
||||
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
|
||||
import { authMiddleware } from '@api/middlewares/auth';
|
||||
import { courseTeamMemberOrAutomationKeyMiddleware } from '@api/middlewares/course-team-member-or-automation-key';
|
||||
import { courseTeamMemberMiddleware } from '@api/middlewares/course-team-member';
|
||||
import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage';
|
||||
import { handleError } from '@api/utils/errors';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
|
||||
export const contentRouter = new Hono()
|
||||
.put(
|
||||
'/reorder',
|
||||
authOrAutomationKeyMiddleware,
|
||||
courseTeamMemberOrAutomationKeyMiddleware(['course:write']),
|
||||
zValidator('json', ZCourseContentReorder),
|
||||
async (c) => {
|
||||
try {
|
||||
const courseId = c.req.param('courseId')!;
|
||||
const automationKey = c.get('automationKey');
|
||||
const payload = c.req.valid('json');
|
||||
|
||||
if (automationKey?.type === 'mcp') {
|
||||
await assertMcpAutomationUsageAllowed(automationKey, 'reorder_course_content');
|
||||
}
|
||||
|
||||
const result = await reorderCourseContent(courseId, payload);
|
||||
|
||||
if (automationKey?.type === 'mcp') {
|
||||
await recordMcpAutomationUsage(automationKey, 'reorder_course_content', {
|
||||
courseId,
|
||||
sectionCount: payload.sections?.length ?? 0,
|
||||
itemCount: payload.items?.length ?? 0
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ success: true, data: result }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to reorder course content');
|
||||
}
|
||||
}
|
||||
)
|
||||
.put('/', authMiddleware, courseTeamMemberMiddleware, zValidator('json', ZCourseContentUpdate), async (c) => {
|
||||
try {
|
||||
const courseId = c.req.param('courseId')!;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ZCourseGetBySlugParam,
|
||||
ZCourseGetParam,
|
||||
ZCourseGetQuery,
|
||||
ZCourseLandingPageUpdate,
|
||||
ZCourseProgressParam,
|
||||
ZCourseProgressQuery,
|
||||
ZCourseUpdate,
|
||||
@@ -30,9 +31,11 @@ import { getCourseTags, replaceCourseTags } from '@api/services/tag';
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { attendanceRouter } from '@api/routes/course/attendance';
|
||||
import { authMiddleware } from '@api/middlewares/auth';
|
||||
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
|
||||
import { cloneCourse } from '@api/services/course/clone';
|
||||
import { contentRouter } from '@api/routes/course/content';
|
||||
import { courseMemberMiddleware } from '@api/middlewares/course-member';
|
||||
import { courseTeamMemberOrAutomationKeyMiddleware } from '@api/middlewares/course-team-member-or-automation-key';
|
||||
import { courseTeamMemberMiddleware } from '@api/middlewares/course-team-member';
|
||||
import { createRateLimiter } from '@api/middlewares/rate-limiter';
|
||||
import { enrollInCourse } from '@api/services/course/invite';
|
||||
@@ -51,8 +54,10 @@ import { orgAdminMiddleware } from '@api/middlewares/org-admin';
|
||||
import { orgMemberMiddleware } from '@api/middlewares/org-member';
|
||||
import { paymentRequestRouter } from '@api/routes/course/payment-request';
|
||||
import { presignRouter } from '@api/routes/course/presign';
|
||||
import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage';
|
||||
import { sectionRouter } from '@api/routes/course/section';
|
||||
import { submissionRouter } from '@api/routes/course/submission';
|
||||
import { updateCourseLandingPageService } from '@api/services/course/landing-page';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
|
||||
const enrollRateLimit = createRateLimiter({
|
||||
@@ -233,6 +238,45 @@ export const courseRouter = new Hono()
|
||||
}
|
||||
}
|
||||
)
|
||||
/**
|
||||
* PUT /course/:courseId/landing-page
|
||||
* Updates landing-page-facing course fields such as copy, media, pricing, and reviews.
|
||||
* Requires authentication and course team membership, or an automation key with course write scope.
|
||||
*/
|
||||
.put(
|
||||
'/:courseId/landing-page',
|
||||
authOrAutomationKeyMiddleware,
|
||||
courseTeamMemberOrAutomationKeyMiddleware(['course:write']),
|
||||
zValidator('param', ZCourseUpdateParam),
|
||||
zValidator('json', ZCourseLandingPageUpdate),
|
||||
async (c) => {
|
||||
try {
|
||||
const { courseId } = c.req.valid('param');
|
||||
const payload = c.req.valid('json');
|
||||
const automationKey = c.get('automationKey');
|
||||
|
||||
if (automationKey?.type === 'mcp') {
|
||||
await assertMcpAutomationUsageAllowed(automationKey, 'update_course_landing_page');
|
||||
}
|
||||
|
||||
const result = await updateCourseLandingPageService(courseId, payload);
|
||||
|
||||
if (automationKey?.type === 'mcp') {
|
||||
await recordMcpAutomationUsage(automationKey, 'update_course_landing_page', { courseId });
|
||||
}
|
||||
|
||||
return c.json(
|
||||
{
|
||||
success: true,
|
||||
data: result
|
||||
},
|
||||
200
|
||||
);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to update course landing page');
|
||||
}
|
||||
}
|
||||
)
|
||||
/**
|
||||
* PUT /course/:courseId
|
||||
* Updates a course
|
||||
|
||||
@@ -21,24 +21,43 @@ import { getGroupMemberIdByCourseAndProfile, isCourseTeamMemberOrOrgAdmin } from
|
||||
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { authMiddleware } from '@api/middlewares/auth';
|
||||
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
|
||||
import { courseMemberMiddleware } from '@api/middlewares/course-member';
|
||||
import { courseMemberOrAutomationKeyMiddleware } from '@api/middlewares/course-member-or-automation-key';
|
||||
import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage';
|
||||
import { createSubmissionService, listExerciseSubmissionsOverview } from '@api/services/submission';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
|
||||
export const exerciseRouter = new Hono()
|
||||
// Exercise CRUD routes
|
||||
.get('/', authMiddleware, courseMemberMiddleware, zValidator('query', ZExerciseListQuery), async (c) => {
|
||||
try {
|
||||
const courseId = c.req.param('courseId')!;
|
||||
const user = c.get('user')!;
|
||||
const { lessonId, sectionId } = c.req.valid('query');
|
||||
const exercises = await listExercises(courseId, { lessonId, sectionId }, user.id);
|
||||
.get(
|
||||
'/',
|
||||
authOrAutomationKeyMiddleware,
|
||||
courseMemberOrAutomationKeyMiddleware(['course:exercise:read']),
|
||||
zValidator('query', ZExerciseListQuery),
|
||||
async (c) => {
|
||||
try {
|
||||
const courseId = c.req.param('courseId')!;
|
||||
const user = c.get('user');
|
||||
const automationKey = c.get('automationKey');
|
||||
const { lessonId, sectionId } = c.req.valid('query');
|
||||
|
||||
return c.json({ success: true, data: exercises }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to list exercises');
|
||||
if (automationKey?.type === 'mcp') {
|
||||
await assertMcpAutomationUsageAllowed(automationKey, 'list_course_exercises');
|
||||
}
|
||||
|
||||
const exercises = await listExercises(courseId, { lessonId, sectionId }, user?.id);
|
||||
|
||||
if (automationKey?.type === 'mcp') {
|
||||
await recordMcpAutomationUsage(automationKey, 'list_course_exercises', { courseId, lessonId, sectionId });
|
||||
}
|
||||
|
||||
return c.json({ success: true, data: exercises }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to list exercises');
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
.get('/:exerciseId/submissions', authMiddleware, courseMemberMiddleware, async (c) => {
|
||||
try {
|
||||
const courseId = c.req.param('courseId')!;
|
||||
@@ -51,28 +70,65 @@ export const exerciseRouter = new Hono()
|
||||
return handleError(c, error, 'Failed to fetch exercise submissions overview');
|
||||
}
|
||||
})
|
||||
.get('/:exerciseId', authMiddleware, courseMemberMiddleware, zValidator('param', ZExerciseGetParam), async (c) => {
|
||||
try {
|
||||
const { exerciseId } = c.req.valid('param');
|
||||
const user = c.get('user')!;
|
||||
const exercise = await getExercise(exerciseId, undefined, user.id);
|
||||
.get(
|
||||
'/:exerciseId',
|
||||
authOrAutomationKeyMiddleware,
|
||||
courseMemberOrAutomationKeyMiddleware(['course:exercise:read']),
|
||||
zValidator('param', ZExerciseGetParam),
|
||||
async (c) => {
|
||||
try {
|
||||
const { exerciseId } = c.req.valid('param');
|
||||
const user = c.get('user');
|
||||
const automationKey = c.get('automationKey');
|
||||
|
||||
return c.json({ success: true, data: exercise }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to fetch exercise');
|
||||
if (automationKey?.type === 'mcp') {
|
||||
await assertMcpAutomationUsageAllowed(automationKey, 'get_course_exercise');
|
||||
}
|
||||
|
||||
const exercise = await getExercise(exerciseId, undefined, user?.id);
|
||||
|
||||
if (automationKey?.type === 'mcp') {
|
||||
await recordMcpAutomationUsage(automationKey, 'get_course_exercise', {
|
||||
courseId: c.req.param('courseId')!,
|
||||
exerciseId
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ success: true, data: exercise }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to fetch exercise');
|
||||
}
|
||||
}
|
||||
})
|
||||
.post('/', authMiddleware, courseMemberMiddleware, zValidator('json', ZExerciseCreate), async (c) => {
|
||||
try {
|
||||
const data = c.req.valid('json');
|
||||
)
|
||||
.post(
|
||||
'/',
|
||||
authOrAutomationKeyMiddleware,
|
||||
courseMemberOrAutomationKeyMiddleware(['course:exercise:write']),
|
||||
zValidator('json', ZExerciseCreate),
|
||||
async (c) => {
|
||||
try {
|
||||
const data = c.req.valid('json');
|
||||
const automationKey = c.get('automationKey');
|
||||
|
||||
const exercise = await createExercise(data);
|
||||
if (automationKey?.type === 'mcp') {
|
||||
await assertMcpAutomationUsageAllowed(automationKey, 'create_course_exercise');
|
||||
}
|
||||
|
||||
return c.json({ success: true, data: exercise }, 201);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to create exercise');
|
||||
const exercise = await createExercise(data);
|
||||
|
||||
if (automationKey?.type === 'mcp') {
|
||||
await recordMcpAutomationUsage(automationKey, 'create_course_exercise', {
|
||||
courseId: c.req.param('courseId')!,
|
||||
exerciseId: exercise.id
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ success: true, data: exercise }, 201);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to create exercise');
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
/**
|
||||
* POST /course/:courseId/exercise/from-template
|
||||
* Creates an exercise from a template
|
||||
@@ -80,14 +136,19 @@ export const exerciseRouter = new Hono()
|
||||
*/
|
||||
.post(
|
||||
'/from-template',
|
||||
authMiddleware,
|
||||
courseMemberMiddleware,
|
||||
authOrAutomationKeyMiddleware,
|
||||
courseMemberOrAutomationKeyMiddleware(['course:exercise:write']),
|
||||
zValidator('json', ZExerciseFromTemplate),
|
||||
async (c) => {
|
||||
try {
|
||||
const courseId = c.req.param('courseId')!;
|
||||
const automationKey = c.get('automationKey');
|
||||
const { lessonId, sectionId, order, templateId } = c.req.valid('json');
|
||||
|
||||
if (automationKey?.type === 'mcp') {
|
||||
await assertMcpAutomationUsageAllowed(automationKey, 'create_course_exercise_from_template');
|
||||
}
|
||||
|
||||
// Fetch template from database
|
||||
const template = await fetchTemplateById(templateId);
|
||||
if (!template) {
|
||||
@@ -100,6 +161,14 @@ export const exerciseRouter = new Hono()
|
||||
|
||||
const exercise = await createExerciseFromTemplate(courseId, lessonId, sectionId, order, template);
|
||||
|
||||
if (automationKey?.type === 'mcp') {
|
||||
await recordMcpAutomationUsage(automationKey, 'create_course_exercise_from_template', {
|
||||
courseId,
|
||||
exerciseId: exercise.id,
|
||||
templateId
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ success: true, data: exercise }, 201);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to create exercise from template');
|
||||
@@ -108,8 +177,8 @@ export const exerciseRouter = new Hono()
|
||||
)
|
||||
.put(
|
||||
'/:exerciseId',
|
||||
authMiddleware,
|
||||
courseMemberMiddleware,
|
||||
authOrAutomationKeyMiddleware,
|
||||
courseMemberOrAutomationKeyMiddleware(['course:exercise:write']),
|
||||
zValidator('param', ZExerciseGetParam),
|
||||
zValidator('json', ZExerciseUpdate),
|
||||
async (c) => {
|
||||
@@ -117,9 +186,14 @@ export const exerciseRouter = new Hono()
|
||||
const { exerciseId } = c.req.valid('param');
|
||||
const data = c.req.valid('json');
|
||||
const courseId = c.req.param('courseId')!;
|
||||
const user = c.get('user')!;
|
||||
const user = c.get('user');
|
||||
const automationKey = c.get('automationKey');
|
||||
|
||||
if (data.isUnlocked !== undefined) {
|
||||
if (automationKey?.type === 'mcp') {
|
||||
await assertMcpAutomationUsageAllowed(automationKey, 'update_course_exercise');
|
||||
}
|
||||
|
||||
if (!automationKey && user && data.isUnlocked !== undefined) {
|
||||
const isAuthorized = await isCourseTeamMemberOrOrgAdmin(courseId, user.id);
|
||||
|
||||
if (!isAuthorized) {
|
||||
@@ -136,6 +210,10 @@ export const exerciseRouter = new Hono()
|
||||
|
||||
const exercise = await updateExerciseService(exerciseId, data);
|
||||
|
||||
if (automationKey?.type === 'mcp') {
|
||||
await recordMcpAutomationUsage(automationKey, 'update_course_exercise', { courseId, exerciseId });
|
||||
}
|
||||
|
||||
return c.json({ success: true, data: exercise }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to update exercise');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ZCreateOrganizationApiKey,
|
||||
ZOrganizationAutomationUsageQuery,
|
||||
ZListOrganizationApiKeysQuery,
|
||||
ZOrganizationApiKeyParam
|
||||
} from '@cio/utils/validation/organization';
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
revokeOrganizationApiKeyService,
|
||||
rotateOrganizationApiKeyService
|
||||
} from '@api/services/organization/automation-key';
|
||||
import { getOrganizationAutomationUsageSummaryService } from '@api/services/organization/automation-usage';
|
||||
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { authMiddleware } from '@api/middlewares/auth';
|
||||
@@ -17,6 +19,23 @@ import { orgAdminMiddleware } from '@api/middlewares/org-admin';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
|
||||
export const automationRouter = new Hono()
|
||||
.get(
|
||||
'/usage',
|
||||
authMiddleware,
|
||||
orgAdminMiddleware,
|
||||
zValidator('query', ZOrganizationAutomationUsageQuery),
|
||||
async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const query = c.req.valid('query');
|
||||
const usage = await getOrganizationAutomationUsageSummaryService(orgId, query.type);
|
||||
|
||||
return c.json({ success: true, data: usage }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to load organization automation usage');
|
||||
}
|
||||
}
|
||||
)
|
||||
.get('/keys', authMiddleware, orgAdminMiddleware, zValidator('query', ZListOrganizationApiKeysQuery), async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { ZAutomationDraftTagAssignment, ZAutomationDraftTagParam } from '@cio/utils/validation/tag';
|
||||
import {
|
||||
ZCourseImportCourseParam,
|
||||
ZCourseImportDraftCreate,
|
||||
ZCourseImportDraftCreateFromCourse,
|
||||
ZCourseImportDraftGetParam,
|
||||
ZCourseImportDraftPublish,
|
||||
ZCourseImportDraftPublishToCourse,
|
||||
ZCourseImportDraftUpdate
|
||||
} from '@cio/utils/validation/course-import';
|
||||
import {
|
||||
createCourseImportDraftFromCourseService,
|
||||
createCourseImportDraftService,
|
||||
getCourseImportDraftService,
|
||||
getCourseImportStructureService,
|
||||
publishCourseImportDraftService,
|
||||
publishCourseImportDraftToExistingCourseService,
|
||||
updateCourseImportDraftTagsService,
|
||||
updateCourseImportDraftService
|
||||
} from '@api/services/course-import/course-import';
|
||||
import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage';
|
||||
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
|
||||
@@ -18,6 +27,33 @@ import { orgAdminOrAutomationKeyMiddleware } from '@api/middlewares/org-admin-or
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
|
||||
export const courseImportRouter = new Hono()
|
||||
.get(
|
||||
'/courses/:courseId/structure',
|
||||
authOrAutomationKeyMiddleware,
|
||||
orgAdminOrAutomationKeyMiddleware(['course_import:draft:read']),
|
||||
zValidator('param', ZCourseImportCourseParam),
|
||||
async (c) => {
|
||||
try {
|
||||
const orgId = c.get('orgId')!;
|
||||
const automationKey = c.get('automationKey');
|
||||
const { courseId } = c.req.valid('param');
|
||||
|
||||
if (automationKey?.type === 'mcp') {
|
||||
await assertMcpAutomationUsageAllowed(automationKey, 'get_course_structure');
|
||||
}
|
||||
|
||||
const structure = await getCourseImportStructureService(orgId, courseId);
|
||||
|
||||
if (automationKey?.type === 'mcp') {
|
||||
await recordMcpAutomationUsage(automationKey, 'get_course_structure', { courseId });
|
||||
}
|
||||
|
||||
return c.json({ success: true, data: structure }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to fetch course structure');
|
||||
}
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/drafts',
|
||||
authOrAutomationKeyMiddleware,
|
||||
@@ -27,16 +63,56 @@ export const courseImportRouter = new Hono()
|
||||
try {
|
||||
const orgId = c.get('orgId')!;
|
||||
const actorId = c.get('actorId')!;
|
||||
const automationKey = c.get('automationKey');
|
||||
const payload = c.req.valid('json');
|
||||
|
||||
if (automationKey?.type === 'mcp') {
|
||||
await assertMcpAutomationUsageAllowed(automationKey, 'create_course_draft');
|
||||
}
|
||||
|
||||
const draft = await createCourseImportDraftService(orgId, actorId, payload);
|
||||
|
||||
if (automationKey?.type === 'mcp') {
|
||||
await recordMcpAutomationUsage(automationKey, 'create_course_draft', { draftId: draft.id });
|
||||
}
|
||||
|
||||
return c.json({ success: true, data: draft }, 201);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to create course import draft');
|
||||
}
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/drafts/from-course',
|
||||
authOrAutomationKeyMiddleware,
|
||||
orgAdminOrAutomationKeyMiddleware(['course_import:draft:create']),
|
||||
zValidator('json', ZCourseImportDraftCreateFromCourse),
|
||||
async (c) => {
|
||||
try {
|
||||
const orgId = c.get('orgId')!;
|
||||
const actorId = c.get('actorId')!;
|
||||
const automationKey = c.get('automationKey');
|
||||
const payload = c.req.valid('json');
|
||||
|
||||
if (automationKey?.type === 'mcp') {
|
||||
await assertMcpAutomationUsageAllowed(automationKey, 'create_course_draft_from_course');
|
||||
}
|
||||
|
||||
const draft = await createCourseImportDraftFromCourseService(orgId, actorId, payload);
|
||||
|
||||
if (automationKey?.type === 'mcp') {
|
||||
await recordMcpAutomationUsage(automationKey, 'create_course_draft_from_course', {
|
||||
draftId: draft.id,
|
||||
courseId: payload.courseId
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ success: true, data: draft }, 201);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to create course draft from existing course');
|
||||
}
|
||||
}
|
||||
)
|
||||
.get(
|
||||
'/drafts/:draftId',
|
||||
authOrAutomationKeyMiddleware,
|
||||
@@ -45,16 +121,54 @@ export const courseImportRouter = new Hono()
|
||||
async (c) => {
|
||||
try {
|
||||
const orgId = c.get('orgId')!;
|
||||
const automationKey = c.get('automationKey');
|
||||
const { draftId } = c.req.valid('param');
|
||||
|
||||
if (automationKey?.type === 'mcp') {
|
||||
await assertMcpAutomationUsageAllowed(automationKey, 'get_course_draft');
|
||||
}
|
||||
|
||||
const draft = await getCourseImportDraftService(orgId, draftId);
|
||||
|
||||
if (automationKey?.type === 'mcp') {
|
||||
await recordMcpAutomationUsage(automationKey, 'get_course_draft', { draftId });
|
||||
}
|
||||
|
||||
return c.json({ success: true, data: draft }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to fetch course import draft');
|
||||
}
|
||||
}
|
||||
)
|
||||
.put(
|
||||
'/drafts/:draftId/tags',
|
||||
authOrAutomationKeyMiddleware,
|
||||
orgAdminOrAutomationKeyMiddleware(['course_import:draft:update']),
|
||||
zValidator('param', ZAutomationDraftTagParam),
|
||||
zValidator('json', ZAutomationDraftTagAssignment),
|
||||
async (c) => {
|
||||
try {
|
||||
const orgId = c.get('orgId')!;
|
||||
const automationKey = c.get('automationKey');
|
||||
const { draftId } = c.req.valid('param');
|
||||
const payload = c.req.valid('json');
|
||||
|
||||
if (automationKey?.type === 'mcp') {
|
||||
await assertMcpAutomationUsageAllowed(automationKey, 'tag_course_draft');
|
||||
}
|
||||
|
||||
const draft = await updateCourseImportDraftTagsService(orgId, draftId, payload.tagNames, payload.mode);
|
||||
|
||||
if (automationKey?.type === 'mcp') {
|
||||
await recordMcpAutomationUsage(automationKey, 'tag_course_draft', { draftId });
|
||||
}
|
||||
|
||||
return c.json({ success: true, data: draft }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to update course draft tags');
|
||||
}
|
||||
}
|
||||
)
|
||||
.put(
|
||||
'/drafts/:draftId',
|
||||
authOrAutomationKeyMiddleware,
|
||||
@@ -64,11 +178,20 @@ export const courseImportRouter = new Hono()
|
||||
async (c) => {
|
||||
try {
|
||||
const orgId = c.get('orgId')!;
|
||||
const automationKey = c.get('automationKey');
|
||||
const { draftId } = c.req.valid('param');
|
||||
const payload = c.req.valid('json');
|
||||
|
||||
if (automationKey?.type === 'mcp') {
|
||||
await assertMcpAutomationUsageAllowed(automationKey, 'update_course_draft');
|
||||
}
|
||||
|
||||
const draft = await updateCourseImportDraftService(orgId, draftId, payload);
|
||||
|
||||
if (automationKey?.type === 'mcp') {
|
||||
await recordMcpAutomationUsage(automationKey, 'update_course_draft', { draftId });
|
||||
}
|
||||
|
||||
return c.json({ success: true, data: draft }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to update course import draft');
|
||||
@@ -85,14 +208,58 @@ export const courseImportRouter = new Hono()
|
||||
try {
|
||||
const orgId = c.get('orgId')!;
|
||||
const actorId = c.get('actorId')!;
|
||||
const automationKey = c.get('automationKey');
|
||||
const { draftId } = c.req.valid('param');
|
||||
const payload = c.req.valid('json');
|
||||
|
||||
if (automationKey?.type === 'mcp') {
|
||||
await assertMcpAutomationUsageAllowed(automationKey, 'publish_course_draft');
|
||||
}
|
||||
|
||||
const result = await publishCourseImportDraftService(orgId, actorId, draftId, payload);
|
||||
|
||||
if (automationKey?.type === 'mcp') {
|
||||
await recordMcpAutomationUsage(automationKey, 'publish_course_draft', {
|
||||
draftId,
|
||||
courseId: result.courseId
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ success: true, data: result }, 201);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to publish course import draft');
|
||||
}
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/drafts/:draftId/publish-existing-course',
|
||||
authOrAutomationKeyMiddleware,
|
||||
orgAdminOrAutomationKeyMiddleware(['course_import:draft:publish']),
|
||||
zValidator('param', ZCourseImportDraftGetParam),
|
||||
zValidator('json', ZCourseImportDraftPublishToCourse),
|
||||
async (c) => {
|
||||
try {
|
||||
const orgId = c.get('orgId')!;
|
||||
const automationKey = c.get('automationKey');
|
||||
const { draftId } = c.req.valid('param');
|
||||
const payload = c.req.valid('json');
|
||||
|
||||
if (automationKey?.type === 'mcp') {
|
||||
await assertMcpAutomationUsageAllowed(automationKey, 'publish_course_draft_to_existing_course');
|
||||
}
|
||||
|
||||
const result = await publishCourseImportDraftToExistingCourseService(orgId, draftId, payload);
|
||||
|
||||
if (automationKey?.type === 'mcp') {
|
||||
await recordMcpAutomationUsage(automationKey, 'publish_course_draft_to_existing_course', {
|
||||
draftId,
|
||||
courseId: payload.courseId
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ success: true, data: result }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to publish course draft to existing course');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -37,15 +37,19 @@ import { TOrganization } from '@db/types';
|
||||
import { automationRouter } from '@api/routes/organization/automation';
|
||||
import { assetsRouter } from '@api/routes/organization/assets';
|
||||
import { authMiddleware } from '@api/middlewares/auth';
|
||||
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
|
||||
import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key';
|
||||
import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage';
|
||||
import { courseImportRouter } from '@api/routes/organization/course-import';
|
||||
import { getLMSExercisesService } from '@api/services/exercise';
|
||||
import { handleError } from '@api/utils/errors';
|
||||
import { inviteTeamMembers } from '@api/services/organization/invite';
|
||||
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
|
||||
import { orgMemberMiddleware } from '@api/middlewares/org-member';
|
||||
import { orgMemberOrAutomationKeyMiddleware } from '@api/middlewares/org-member-or-automation-key';
|
||||
import { quizRouter } from '@api/routes/organization/quiz';
|
||||
import { tagsRouter } from '@api/routes/organization/tags';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
|
||||
export const organizationRouter = new Hono()
|
||||
@@ -287,17 +291,18 @@ export const organizationRouter = new Hono()
|
||||
*/
|
||||
.get(
|
||||
'/courses',
|
||||
authMiddleware,
|
||||
orgMemberMiddleware,
|
||||
authOrAutomationKeyMiddleware,
|
||||
orgMemberOrAutomationKeyMiddleware(['course:read']),
|
||||
zValidator('query', ZGetOrganizationCoursesQuery),
|
||||
async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const user = c.get('user');
|
||||
const orgId = c.get('orgId');
|
||||
const userRole = c.get('userRole');
|
||||
const automationKey = c.get('automationKey');
|
||||
const { tags } = c.req.valid('query');
|
||||
|
||||
if (!orgId || userRole === null) {
|
||||
if (!orgId) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
@@ -308,22 +313,25 @@ export const organizationRouter = new Hono()
|
||||
);
|
||||
}
|
||||
|
||||
if (automationKey?.type === 'mcp') {
|
||||
await assertMcpAutomationUsageAllowed(automationKey, 'list_org_courses');
|
||||
}
|
||||
|
||||
const tagSlugs = tags
|
||||
?.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
console.log('user', user);
|
||||
console.log('userRole', userRole);
|
||||
console.log('orgId', orgId);
|
||||
console.log('tagSlugs', tagSlugs);
|
||||
const result = await getOrganizationCourses(
|
||||
orgId,
|
||||
user.id,
|
||||
userRole,
|
||||
user?.id ?? automationKey?.createdByProfileId ?? '',
|
||||
userRole ?? ROLE.ADMIN,
|
||||
tagSlugs && tagSlugs.length > 0 ? tagSlugs : undefined
|
||||
);
|
||||
|
||||
if (automationKey?.type === 'mcp') {
|
||||
await recordMcpAutomationUsage(automationKey, 'list_org_courses', { tags: tagSlugs });
|
||||
}
|
||||
|
||||
return c.json(
|
||||
{
|
||||
success: true,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
ZAutomationCourseTagAssignment,
|
||||
ZTagCreate,
|
||||
ZTagGroupCreate,
|
||||
ZTagGroupParam,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
ZTagUpdate
|
||||
} from '@cio/utils/validation/tag';
|
||||
import {
|
||||
assignTagsToCoursesByName,
|
||||
createOrganizationTag,
|
||||
createOrganizationTagGroup,
|
||||
getPublicOrganizationTagGroups,
|
||||
@@ -18,8 +20,11 @@ import { ZGetCoursesBySiteName } from '@cio/utils/validation/organization';
|
||||
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { authMiddleware } from '@api/middlewares/auth';
|
||||
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
|
||||
import { handleError } from '@api/utils/errors';
|
||||
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
|
||||
import { orgAdminOrAutomationKeyMiddleware } from '@api/middlewares/org-admin-or-automation-key';
|
||||
import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
|
||||
export const tagsRouter = new Hono()
|
||||
@@ -72,6 +77,39 @@ export const tagsRouter = new Hono()
|
||||
return handleError(c, error, 'Failed to create tag group');
|
||||
}
|
||||
})
|
||||
.put(
|
||||
'/courses/assign',
|
||||
authOrAutomationKeyMiddleware,
|
||||
orgAdminOrAutomationKeyMiddleware(['course:tag:write']),
|
||||
zValidator('json', ZAutomationCourseTagAssignment),
|
||||
async (c) => {
|
||||
try {
|
||||
const orgId = c.get('orgId')!;
|
||||
const automationKey = c.get('automationKey');
|
||||
const data = c.req.valid('json');
|
||||
|
||||
if (automationKey?.type === 'mcp') {
|
||||
await assertMcpAutomationUsageAllowed(automationKey, 'tag_courses');
|
||||
}
|
||||
|
||||
const result = await assignTagsToCoursesByName(orgId, data);
|
||||
|
||||
if (automationKey?.type === 'mcp') {
|
||||
await recordMcpAutomationUsage(automationKey, 'tag_courses', { courseIds: data.courseIds });
|
||||
}
|
||||
|
||||
return c.json(
|
||||
{
|
||||
success: true,
|
||||
data: result
|
||||
},
|
||||
200
|
||||
);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to assign course tags');
|
||||
}
|
||||
}
|
||||
)
|
||||
.put(
|
||||
'/groups/:groupId',
|
||||
authMiddleware,
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { env } from '@api/config/env';
|
||||
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
||||
import { createCourse, updateCourse } from '@api/services/course/course';
|
||||
import { createCourseSection, listCourseSections, updateCourseSectionService } from '@api/services/course/section';
|
||||
import { createLesson, listLessons, updateLessonService } from '@api/services/lesson';
|
||||
import { upsertLessonLanguageService, updateLessonLanguageService } from '@api/services/lesson-language';
|
||||
import {
|
||||
createCourseImportDraft,
|
||||
getCourseImportDraftById,
|
||||
getCourseImportDraftByIdempotencyKey,
|
||||
updateCourseImportDraft
|
||||
} from '@cio/db/queries/course-import';
|
||||
import type { TCourseImportDraft, TLocale } from '@db/types';
|
||||
import type {
|
||||
TCourseImportDraftCreate,
|
||||
TCourseImportDraftPayload,
|
||||
TCourseImportDraftPublish,
|
||||
TCourseImportDraftUpdate
|
||||
import { getCourseById } from '@cio/db/queries/course';
|
||||
import { getLessonLanguagesByLessonIds } from '@cio/db/queries/lesson';
|
||||
import { getOrganizationById } from '@cio/db/queries/organization';
|
||||
import { getCourseTagsForOrganization } from '@cio/db/queries/tag';
|
||||
import type { TCourse, TCourseImportDraft, TLessonLanguage, TLocale, TOrganization } from '@db/types';
|
||||
import {
|
||||
type TCourseImportDraftCreate,
|
||||
type TCourseImportDraftCreateFromCourse,
|
||||
type TCourseImportDraftPayload,
|
||||
type TCourseImportDraftPublish,
|
||||
type TCourseImportDraftPublishToCourse,
|
||||
type TCourseImportDraftUpdate,
|
||||
ZCourseImportDraftPayload,
|
||||
ZCourseImportWarning
|
||||
} from '@cio/utils/validation/course-import';
|
||||
import { ZCourseImportDraftPayload } from '@cio/utils/validation/course-import';
|
||||
import { createCourse, updateCourse } from '@api/services/course/course';
|
||||
import { createCourseSection } from '@api/services/course/section';
|
||||
import { createLesson } from '@api/services/lesson';
|
||||
import { upsertLessonLanguageService } from '@api/services/lesson-language';
|
||||
import { getCourseOrganizationId } from '@cio/db/queries/tag';
|
||||
import { assignCourseTagsByName } from '@api/services/tag';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import * as schema from '@cio/db/schema';
|
||||
import { generateSlug } from '@cio/utils/functions';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
type DraftSummary = {
|
||||
sectionCount: number;
|
||||
@@ -25,6 +40,30 @@ type DraftSummary = {
|
||||
warningCount: number;
|
||||
};
|
||||
|
||||
type CourseStructureSnapshot = {
|
||||
courseId: string;
|
||||
draft: TCourseImportDraftPayload;
|
||||
};
|
||||
|
||||
type PublishDraftResult = {
|
||||
courseId: string;
|
||||
courseUrl: string;
|
||||
bannerImageUrl: string | null;
|
||||
tagNames: string[];
|
||||
createdSections: number;
|
||||
createdLessons: number;
|
||||
localeCount: number;
|
||||
};
|
||||
|
||||
type PublishDraftToExistingCourseResult = PublishDraftResult & {
|
||||
updatedSections: number;
|
||||
updatedLessons: number;
|
||||
createdLessonLanguages: number;
|
||||
updatedLessonLanguages: number;
|
||||
untouchedSections: number;
|
||||
untouchedLessons: number;
|
||||
};
|
||||
|
||||
function buildDraftSummary(draft: TCourseImportDraftPayload): DraftSummary {
|
||||
return {
|
||||
sectionCount: draft.sections.length,
|
||||
@@ -34,10 +73,314 @@ function buildDraftSummary(draft: TCourseImportDraftPayload): DraftSummary {
|
||||
};
|
||||
}
|
||||
|
||||
async function generateUniqueCourseSlug(baseSlug: string): Promise<string> {
|
||||
let suffix = 0;
|
||||
let candidate = baseSlug;
|
||||
|
||||
while (true) {
|
||||
const existing = await db
|
||||
.select({ id: schema.course.id })
|
||||
.from(schema.course)
|
||||
.where(eq(schema.course.slug, candidate))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
suffix += 1;
|
||||
candidate = `${baseSlug}-${suffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCourseSlug(courseId: string, title: string | null | undefined): Promise<string> {
|
||||
const [course] = await getCourseById(courseId);
|
||||
if (course?.slug) {
|
||||
return course.slug;
|
||||
}
|
||||
|
||||
const baseSlug = generateSlug(title, { fallback: 'course' });
|
||||
const uniqueSlug = await generateUniqueCourseSlug(baseSlug);
|
||||
await updateCourse(courseId, { slug: uniqueSlug });
|
||||
return uniqueSlug;
|
||||
}
|
||||
|
||||
function buildCourseBaseUrl(organization: TOrganization) {
|
||||
if (organization.customDomain && organization.isCustomDomainVerified) {
|
||||
return `https://${organization.customDomain}`;
|
||||
}
|
||||
|
||||
return getDashboardBaseUrl(organization.siteName ?? undefined);
|
||||
}
|
||||
|
||||
async function resolveCourseUrl(organizationId: string, courseId: string, title: string) {
|
||||
const organization = await getOrganizationById(organizationId);
|
||||
if (!organization) {
|
||||
throw new AppError('Organization not found', ErrorCodes.ORG_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const slug = await ensureCourseSlug(courseId, title);
|
||||
return `${buildCourseBaseUrl(organization)}/course/${encodeURIComponent(slug)}`;
|
||||
}
|
||||
|
||||
async function resolveUnsplashBannerImage(courseTitle: string, query?: string) {
|
||||
if (!env.UNSPLASH_API_KEY) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const searchQuery = (query?.trim() || courseTitle.trim() || 'education').slice(0, 120);
|
||||
const response = await fetch(
|
||||
`https://api.unsplash.com/search/photos?page=1&per_page=15&auto=format&fit=crop&w=2970&q=80&client_id=${env.UNSPLASH_API_KEY}&query=${encodeURIComponent(searchQuery)}`,
|
||||
{ method: 'GET' }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
results?: Array<{
|
||||
urls?: {
|
||||
regular?: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
const photos = data.results ?? [];
|
||||
if (photos.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const photo = photos[Math.floor(Math.random() * photos.length)];
|
||||
return photo?.urls?.regular ?? null;
|
||||
}
|
||||
|
||||
async function resolvePublishBannerImage(
|
||||
title: string,
|
||||
overrides: Pick<TCourseImportDraftPublish, 'bannerImageQuery' | 'bannerImageUrl' | 'generateBannerImage'>
|
||||
) {
|
||||
if (overrides.bannerImageUrl) {
|
||||
return overrides.bannerImageUrl;
|
||||
}
|
||||
|
||||
if (!overrides.generateBannerImage && !overrides.bannerImageQuery) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await resolveUnsplashBannerImage(title, overrides.bannerImageQuery);
|
||||
} catch (error) {
|
||||
console.error('resolvePublishBannerImage error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function maybeApplyCourseBannerImage(courseId: string, imageUrl: string | null) {
|
||||
if (!imageUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await updateCourse(courseId, {
|
||||
logo: imageUrl,
|
||||
bannerImage: imageUrl
|
||||
});
|
||||
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
function normalizeDraftTagNames(tagNames: string[]) {
|
||||
return Array.from(new Map(tagNames.map((tag) => [tag.trim().toLowerCase(), tag.trim()])).values()).filter(Boolean);
|
||||
}
|
||||
|
||||
function parseStoredDraft(record: TCourseImportDraft): TCourseImportDraftPayload {
|
||||
return ZCourseImportDraftPayload.parse(record.draft);
|
||||
}
|
||||
|
||||
async function assertCourseBelongsToOrganization(orgId: string, courseId: string) {
|
||||
const courseOrgId = await getCourseOrganizationId(courseId);
|
||||
|
||||
if (!courseOrgId) {
|
||||
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
if (courseOrgId !== orgId) {
|
||||
throw new AppError('Invalid permissions', ErrorCodes.UNAUTHORIZED, 403);
|
||||
}
|
||||
}
|
||||
|
||||
async function getCourseRecord(orgId: string, courseId: string): Promise<TCourse> {
|
||||
await assertCourseBelongsToOrganization(orgId, courseId);
|
||||
|
||||
const [course] = await getCourseById(courseId);
|
||||
if (!course) {
|
||||
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return course;
|
||||
}
|
||||
|
||||
function inferDraftLocale(languages: TLessonLanguage[]): TLocale {
|
||||
const locale = languages[0]?.locale;
|
||||
return (locale ?? 'en') as TLocale;
|
||||
}
|
||||
|
||||
function getPrimaryLessonContent(
|
||||
lessonExternalId: string,
|
||||
lessonLanguages: TCourseImportDraftPayload['lessonLanguages'],
|
||||
preferredLocale: TLocale
|
||||
) {
|
||||
const localized = lessonLanguages.find(
|
||||
(lessonLanguage) =>
|
||||
lessonLanguage.lessonExternalId === lessonExternalId && lessonLanguage.locale === preferredLocale
|
||||
);
|
||||
|
||||
if (localized) {
|
||||
return localized.content;
|
||||
}
|
||||
|
||||
return lessonLanguages.find((lessonLanguage) => lessonLanguage.lessonExternalId === lessonExternalId)?.content;
|
||||
}
|
||||
|
||||
async function buildCourseStructureSnapshot(orgId: string, courseId: string): Promise<CourseStructureSnapshot> {
|
||||
const course = await getCourseRecord(orgId, courseId);
|
||||
const [sections, lessons, tags] = await Promise.all([
|
||||
listCourseSections(courseId),
|
||||
listLessons(courseId),
|
||||
getCourseTagsForOrganization(orgId, courseId)
|
||||
]);
|
||||
|
||||
if (lessons.length === 0) {
|
||||
throw new AppError(
|
||||
'Course must contain at least one lesson before it can be imported into a draft',
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const lessonIds = lessons.map((lesson) => lesson.id);
|
||||
const lessonLanguages = await getLessonLanguagesByLessonIds(lessonIds);
|
||||
const fallbackLocale = inferDraftLocale(lessonLanguages);
|
||||
const warnings: TCourseImportDraftPayload['warnings'] = [];
|
||||
const normalizedSections = [...sections]
|
||||
.sort((a, b) => Number(a.order ?? 0) - Number(b.order ?? 0))
|
||||
.map((section) => ({
|
||||
externalId: section.id,
|
||||
title: section.title ?? 'Untitled Section',
|
||||
order: Number(section.order ?? 0)
|
||||
}));
|
||||
|
||||
let sectionReferenceMap = new Map(normalizedSections.map((section) => [section.externalId, section.externalId]));
|
||||
|
||||
if (normalizedSections.length === 0 || lessons.some((lesson) => !lesson.sectionId)) {
|
||||
const syntheticSectionId = `synthetic:${courseId}:ungrouped`;
|
||||
normalizedSections.push({
|
||||
externalId: syntheticSectionId,
|
||||
title: 'Ungrouped',
|
||||
order: normalizedSections.length
|
||||
});
|
||||
sectionReferenceMap = new Map(normalizedSections.map((section) => [section.externalId, section.externalId]));
|
||||
warnings.push(
|
||||
ZCourseImportWarning.parse({
|
||||
code: 'UNGROUPED_CONTENT_NORMALIZED',
|
||||
message: 'Some lessons were ungrouped. A synthetic section was added to the draft.',
|
||||
severity: 'warning'
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedLessons = [...lessons]
|
||||
.sort((a, b) => Number(a.order ?? 0) - Number(b.order ?? 0))
|
||||
.map((lesson) => ({
|
||||
externalId: lesson.id,
|
||||
sectionExternalId:
|
||||
lesson.sectionId && sectionReferenceMap.has(lesson.sectionId)
|
||||
? lesson.sectionId
|
||||
: normalizedSections[normalizedSections.length - 1]!.externalId,
|
||||
title: lesson.title,
|
||||
order: Number(lesson.order ?? 0),
|
||||
isUnlocked: lesson.isUnlocked ?? undefined,
|
||||
public: lesson.public ?? undefined
|
||||
}));
|
||||
|
||||
const normalizedLessonLanguages: TCourseImportDraftPayload['lessonLanguages'] = [];
|
||||
|
||||
for (const lesson of lessons) {
|
||||
const localized = lessonLanguages.filter((lessonLanguage) => lessonLanguage.lessonId === lesson.id);
|
||||
|
||||
if (localized.length > 0) {
|
||||
const validLocalized = localized
|
||||
.map((lessonLanguage) => ({
|
||||
lessonExternalId: lesson.id,
|
||||
locale: lessonLanguage.locale as TLocale,
|
||||
content: lessonLanguage.content?.trim() ?? ''
|
||||
}))
|
||||
.filter((lessonLanguage) => lessonLanguage.content.length > 0);
|
||||
|
||||
if (validLocalized.length > 0) {
|
||||
normalizedLessonLanguages.push(...validLocalized);
|
||||
continue;
|
||||
}
|
||||
|
||||
warnings.push(
|
||||
ZCourseImportWarning.parse({
|
||||
code: 'EMPTY_LOCALE_CONTENT_SKIPPED',
|
||||
message: `Lesson "${lesson.title}" had empty localized content and required a fallback.`,
|
||||
severity: 'warning'
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (lesson.note?.trim()) {
|
||||
normalizedLessonLanguages.push({
|
||||
lessonExternalId: lesson.id,
|
||||
locale: fallbackLocale,
|
||||
content: lesson.note
|
||||
});
|
||||
warnings.push(
|
||||
ZCourseImportWarning.parse({
|
||||
code: 'LEGACY_NOTE_FALLBACK',
|
||||
message: `Lesson "${lesson.title}" used legacy note content because no lesson_language rows were found.`,
|
||||
severity: 'info'
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedLessonLanguages.length === 0) {
|
||||
throw new AppError(
|
||||
'Course lessons must contain note or localized content before they can be imported into a draft',
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const draft = ZCourseImportDraftPayload.parse({
|
||||
course: {
|
||||
title: course.title,
|
||||
description: course.description,
|
||||
type: course.type,
|
||||
locale: fallbackLocale,
|
||||
metadata: course.metadata
|
||||
},
|
||||
tags: tags.map((tag) => tag.name),
|
||||
sections: normalizedSections,
|
||||
lessons: normalizedLessons,
|
||||
lessonLanguages: normalizedLessonLanguages,
|
||||
sourceReferences: [
|
||||
{
|
||||
type: 'course',
|
||||
label: course.title
|
||||
}
|
||||
],
|
||||
warnings
|
||||
});
|
||||
|
||||
return {
|
||||
courseId,
|
||||
draft
|
||||
};
|
||||
}
|
||||
|
||||
export async function createCourseImportDraftService(orgId: string, profileId: string, data: TCourseImportDraftCreate) {
|
||||
try {
|
||||
if (data.idempotencyKey) {
|
||||
@@ -76,6 +419,48 @@ export async function createCourseImportDraftService(orgId: string, profileId: s
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCourseImportDraftFromCourseService(
|
||||
orgId: string,
|
||||
profileId: string,
|
||||
data: TCourseImportDraftCreateFromCourse
|
||||
) {
|
||||
const snapshot = await buildCourseStructureSnapshot(orgId, data.courseId);
|
||||
|
||||
return createCourseImportDraftService(orgId, profileId, {
|
||||
sourceType: 'course',
|
||||
idempotencyKey: data.idempotencyKey,
|
||||
summary: {
|
||||
sourceCourseId: data.courseId,
|
||||
...(data.summary ?? {})
|
||||
},
|
||||
sourceArtifacts: [
|
||||
{
|
||||
type: 'course',
|
||||
courseId: data.courseId,
|
||||
label: snapshot.draft.course.title
|
||||
},
|
||||
...(data.sourceArtifacts ?? [])
|
||||
],
|
||||
draft: snapshot.draft
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCourseImportStructureService(orgId: string, courseId: string) {
|
||||
try {
|
||||
return await buildCourseStructureSnapshot(orgId, courseId);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to fetch course structure',
|
||||
ErrorCodes.COURSE_IMPORT_FETCH_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCourseImportDraftService(orgId: string, draftId: string) {
|
||||
try {
|
||||
const draft = await getCourseImportDraftById(orgId, draftId);
|
||||
@@ -138,18 +523,52 @@ export async function updateCourseImportDraftService(orgId: string, draftId: str
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCourseImportDraftTagsService(
|
||||
orgId: string,
|
||||
draftId: string,
|
||||
tagNames: string[],
|
||||
mode: 'merge' | 'replace'
|
||||
) {
|
||||
const existing = await getCourseImportDraftService(orgId, draftId);
|
||||
|
||||
if (existing.status === 'PUBLISHED') {
|
||||
throw new AppError('Published drafts cannot be edited', ErrorCodes.FORBIDDEN, 403);
|
||||
}
|
||||
|
||||
const draft = parseStoredDraft(existing);
|
||||
const nextTags =
|
||||
mode === 'replace' ? normalizeDraftTagNames(tagNames) : normalizeDraftTagNames([...draft.tags, ...tagNames]);
|
||||
|
||||
return updateCourseImportDraftService(orgId, draftId, {
|
||||
draft: {
|
||||
...draft,
|
||||
tags: nextTags
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function publishCourseImportDraftService(
|
||||
orgId: string,
|
||||
profileId: string,
|
||||
draftId: string,
|
||||
overrides: TCourseImportDraftPublish
|
||||
) {
|
||||
): Promise<PublishDraftResult> {
|
||||
try {
|
||||
const existing = await getCourseImportDraftService(orgId, draftId);
|
||||
|
||||
if (existing.status === 'PUBLISHED' && existing.publishedCourseId) {
|
||||
const [publishedCourse] = await getCourseById(existing.publishedCourseId);
|
||||
const courseUrl = await resolveCourseUrl(
|
||||
orgId,
|
||||
existing.publishedCourseId,
|
||||
publishedCourse?.title ?? existing.title
|
||||
);
|
||||
const draft = parseStoredDraft(existing);
|
||||
return {
|
||||
courseId: existing.publishedCourseId,
|
||||
courseUrl,
|
||||
bannerImageUrl: publishedCourse?.logo ?? null,
|
||||
tagNames: normalizeDraftTagNames(draft.tags),
|
||||
createdSections: 0,
|
||||
createdLessons: 0,
|
||||
localeCount: 0
|
||||
@@ -182,6 +601,9 @@ export async function publishCourseImportDraftService(
|
||||
await updateCourse(courseId, { metadata: courseMetadata });
|
||||
}
|
||||
|
||||
const bannerImageUrl = await resolvePublishBannerImage(courseTitle, overrides);
|
||||
await maybeApplyCourseBannerImage(courseId, bannerImageUrl);
|
||||
|
||||
const sectionIdMap = new Map<string, string>();
|
||||
for (const section of [...draft.sections].sort((a, b) => a.order - b.order)) {
|
||||
const createdSection = await createCourseSection(courseId, {
|
||||
@@ -203,6 +625,7 @@ export async function publishCourseImportDraftService(
|
||||
const createdLesson = await createLesson(courseId, {
|
||||
courseId,
|
||||
title: lesson.title,
|
||||
note: getPrimaryLessonContent(lesson.externalId, draft.lessonLanguages, draft.course.locale as TLocale),
|
||||
sectionId,
|
||||
order: lesson.order,
|
||||
isUnlocked: lesson.isUnlocked,
|
||||
@@ -224,6 +647,16 @@ export async function publishCourseImportDraftService(
|
||||
});
|
||||
}
|
||||
|
||||
const appliedTagNames = normalizeDraftTagNames(draft.tags);
|
||||
if (appliedTagNames.length > 0) {
|
||||
await assignCourseTagsByName(orgId, courseId, {
|
||||
tagNames: appliedTagNames,
|
||||
mode: 'replace'
|
||||
});
|
||||
}
|
||||
|
||||
const courseUrl = await resolveCourseUrl(orgId, courseId, courseTitle);
|
||||
|
||||
await updateCourseImportDraft(orgId, draftId, {
|
||||
status: 'PUBLISHED',
|
||||
publishedCourseId: courseId,
|
||||
@@ -236,6 +669,9 @@ export async function publishCourseImportDraftService(
|
||||
|
||||
return {
|
||||
courseId,
|
||||
courseUrl,
|
||||
bannerImageUrl,
|
||||
tagNames: appliedTagNames,
|
||||
createdSections: sectionIdMap.size,
|
||||
createdLessons: lessonIdMap.size,
|
||||
localeCount: new Set(draft.lessonLanguages.map((item) => item.locale)).size
|
||||
@@ -252,3 +688,184 @@ export async function publishCourseImportDraftService(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function publishCourseImportDraftToExistingCourseService(
|
||||
orgId: string,
|
||||
draftId: string,
|
||||
overrides: TCourseImportDraftPublishToCourse
|
||||
): Promise<PublishDraftToExistingCourseResult> {
|
||||
try {
|
||||
const existingDraft = await getCourseImportDraftService(orgId, draftId);
|
||||
const draft = parseStoredDraft(existingDraft);
|
||||
const course = await getCourseRecord(orgId, overrides.courseId);
|
||||
const courseTitle = overrides.title ?? draft.course.title;
|
||||
const courseDescription = overrides.description ?? draft.course.description;
|
||||
const courseType = overrides.type ?? draft.course.type;
|
||||
const courseMetadata = overrides.metadata ?? draft.course.metadata;
|
||||
const preferredLocale = draft.course.locale as TLocale;
|
||||
const bannerImageUrl = await resolvePublishBannerImage(courseTitle, overrides);
|
||||
|
||||
await updateCourse(course.id, {
|
||||
title: courseTitle,
|
||||
description: courseDescription,
|
||||
type: courseType,
|
||||
metadata: courseMetadata
|
||||
});
|
||||
|
||||
await maybeApplyCourseBannerImage(course.id, bannerImageUrl);
|
||||
|
||||
const [existingSections, existingLessons] = await Promise.all([
|
||||
listCourseSections(course.id),
|
||||
listLessons(course.id)
|
||||
]);
|
||||
const existingSectionIds = new Set(existingSections.map((section) => section.id));
|
||||
const existingLessonIds = new Set(existingLessons.map((lesson) => lesson.id));
|
||||
const existingLessonLanguages = await getLessonLanguagesByLessonIds(existingLessons.map((lesson) => lesson.id));
|
||||
|
||||
const sectionIdMap = new Map<string, string>();
|
||||
let createdSections = 0;
|
||||
let updatedSections = 0;
|
||||
|
||||
for (const section of [...draft.sections].sort((a, b) => a.order - b.order)) {
|
||||
if (existingSectionIds.has(section.externalId)) {
|
||||
const updatedSection = await updateCourseSectionService(section.externalId, {
|
||||
title: section.title,
|
||||
order: section.order
|
||||
});
|
||||
sectionIdMap.set(section.externalId, updatedSection.id);
|
||||
updatedSections += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const createdSection = await createCourseSection(course.id, {
|
||||
courseId: course.id,
|
||||
title: section.title,
|
||||
order: section.order
|
||||
});
|
||||
sectionIdMap.set(section.externalId, createdSection.id);
|
||||
createdSections += 1;
|
||||
}
|
||||
|
||||
const lessonIdMap = new Map<string, string>();
|
||||
let createdLessons = 0;
|
||||
let updatedLessons = 0;
|
||||
|
||||
for (const lesson of [...draft.lessons].sort((a, b) => a.order - b.order)) {
|
||||
const sectionId = sectionIdMap.get(lesson.sectionExternalId);
|
||||
if (!sectionId) {
|
||||
throw new AppError(`Lesson "${lesson.title}" references an unknown section`, ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
const note = getPrimaryLessonContent(lesson.externalId, draft.lessonLanguages, preferredLocale);
|
||||
|
||||
if (existingLessonIds.has(lesson.externalId)) {
|
||||
const updatedLesson = await updateLessonService(lesson.externalId, {
|
||||
title: lesson.title,
|
||||
note,
|
||||
sectionId,
|
||||
order: lesson.order,
|
||||
isUnlocked: lesson.isUnlocked,
|
||||
public: lesson.public
|
||||
});
|
||||
lessonIdMap.set(lesson.externalId, updatedLesson.id);
|
||||
updatedLessons += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const createdLesson = await createLesson(course.id, {
|
||||
courseId: course.id,
|
||||
title: lesson.title,
|
||||
note,
|
||||
sectionId,
|
||||
order: lesson.order,
|
||||
isUnlocked: lesson.isUnlocked,
|
||||
public: lesson.public
|
||||
});
|
||||
lessonIdMap.set(lesson.externalId, createdLesson.id);
|
||||
createdLessons += 1;
|
||||
}
|
||||
|
||||
const existingLessonLanguageMap = new Map(
|
||||
existingLessonLanguages.map((lessonLanguage) => [
|
||||
`${lessonLanguage.lessonId}:${lessonLanguage.locale}`,
|
||||
lessonLanguage
|
||||
])
|
||||
);
|
||||
let createdLessonLanguages = 0;
|
||||
let updatedLessonLanguages = 0;
|
||||
|
||||
for (const lessonLanguage of draft.lessonLanguages) {
|
||||
const lessonId = lessonIdMap.get(lessonLanguage.lessonExternalId);
|
||||
if (!lessonId) {
|
||||
throw new AppError('Referenced lesson was not created', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
const languageKey = `${lessonId}:${lessonLanguage.locale}`;
|
||||
if (existingLessonLanguageMap.has(languageKey)) {
|
||||
await updateLessonLanguageService(lessonId, lessonLanguage.locale as TLocale, {
|
||||
content: lessonLanguage.content
|
||||
});
|
||||
updatedLessonLanguages += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await upsertLessonLanguageService(lessonId, {
|
||||
locale: lessonLanguage.locale as TLocale,
|
||||
content: lessonLanguage.content
|
||||
});
|
||||
createdLessonLanguages += 1;
|
||||
}
|
||||
|
||||
const appliedTagNames = normalizeDraftTagNames(draft.tags);
|
||||
if (appliedTagNames.length > 0) {
|
||||
await assignCourseTagsByName(orgId, course.id, {
|
||||
tagNames: appliedTagNames,
|
||||
mode: 'merge'
|
||||
});
|
||||
}
|
||||
|
||||
const referencedSectionIds = new Set([...sectionIdMap.values()].filter((id) => existingSectionIds.has(id)));
|
||||
const referencedLessonIds = new Set([...lessonIdMap.values()].filter((id) => existingLessonIds.has(id)));
|
||||
const courseUrl = await resolveCourseUrl(orgId, course.id, courseTitle);
|
||||
|
||||
await updateCourseImportDraft(orgId, draftId, {
|
||||
status: 'PUBLISHED',
|
||||
publishedCourseId: course.id,
|
||||
summary: {
|
||||
...(existingDraft.summary ?? {}),
|
||||
...buildDraftSummary(draft),
|
||||
publishedCourseId: course.id,
|
||||
updatedSections,
|
||||
updatedLessons,
|
||||
createdLessonLanguages,
|
||||
updatedLessonLanguages
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
courseId: course.id,
|
||||
courseUrl,
|
||||
bannerImageUrl,
|
||||
tagNames: appliedTagNames,
|
||||
createdSections,
|
||||
updatedSections,
|
||||
createdLessons,
|
||||
updatedLessons,
|
||||
createdLessonLanguages,
|
||||
updatedLessonLanguages,
|
||||
untouchedSections: existingSections.length - referencedSectionIds.size,
|
||||
untouchedLessons: existingLessons.length - referencedLessonIds.size,
|
||||
localeCount: new Set(draft.lessonLanguages.map((item) => item.locale)).size
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to publish course import draft to existing course',
|
||||
ErrorCodes.COURSE_IMPORT_PUBLISH_FAILED,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { ContentType } from '@cio/utils/constants';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import { db, type DbOrTxClient } from '@cio/db/drizzle';
|
||||
import { sql, inArray } from 'drizzle-orm';
|
||||
import { lesson, exercise } from '@cio/db/schema';
|
||||
import { courseSection, lesson, exercise } from '@cio/db/schema';
|
||||
import { getCourseContentItems, type CourseContentItemRow } from '@cio/db/queries/course/content';
|
||||
import { deleteLesson } from '@cio/db/queries/lesson';
|
||||
import { deleteCourseSection, getCourseSectionById } from '@cio/db/queries/course';
|
||||
import { deleteCourseSection, getCourseSectionById, getCourseSectionsByCourseId } from '@cio/db/queries/course';
|
||||
import { deleteExercise } from '@cio/db/queries/exercise/exercise';
|
||||
import type { TCourseContentDelete, TCourseContentUpdate } from '@cio/utils/validation/course';
|
||||
import type { TCourseContentDelete, TCourseContentReorder, TCourseContentUpdate } from '@cio/utils/validation/course';
|
||||
|
||||
type CourseContentReorderResult = {
|
||||
courseId: string;
|
||||
updatedSections: number;
|
||||
updatedItems: number;
|
||||
updatedLessons: number;
|
||||
updatedExercises: number;
|
||||
};
|
||||
|
||||
function buildUpdatePayload(item: TCourseContentUpdate['items'][number]) {
|
||||
const payload: Record<string, unknown> = {};
|
||||
@@ -64,122 +72,175 @@ async function assertCourseContentItems(courseId: string, items: TCourseContentU
|
||||
});
|
||||
}
|
||||
|
||||
async function assertCourseSections(courseId: string, sections: NonNullable<TCourseContentReorder['sections']>) {
|
||||
const existingSections = await getCourseSectionsByCourseId(courseId);
|
||||
const sectionIds = new Set(existingSections.map((section) => section.id));
|
||||
|
||||
sections.forEach((section) => {
|
||||
if (!sectionIds.has(section.id)) {
|
||||
throw new AppError('Course section not found', ErrorCodes.COURSE_SECTION_NOT_FOUND, 404);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function applyCourseContentUpdates(tx: DbOrTxClient, items: TCourseContentUpdate['items']) {
|
||||
const updates = items
|
||||
.map((item) => ({
|
||||
item,
|
||||
payload: buildUpdatePayload(item)
|
||||
}))
|
||||
.filter(({ payload }) => Object.keys(payload).length > 0);
|
||||
|
||||
if (updates.length === 0) {
|
||||
return {
|
||||
updatedItems: 0,
|
||||
updatedLessons: 0,
|
||||
updatedExercises: 0
|
||||
};
|
||||
}
|
||||
|
||||
const updatedAt = new Date().toISOString();
|
||||
|
||||
const lessonUpdates = updates.filter(({ item }) => item.type === ContentType.Lesson);
|
||||
const exerciseUpdates = updates.filter(({ item }) => item.type === ContentType.Exercise);
|
||||
|
||||
if (lessonUpdates.length > 0) {
|
||||
const lessonIds = lessonUpdates.map(({ item }) => item.id);
|
||||
const lessonOrderUpdates = lessonUpdates
|
||||
.filter(({ payload }) => payload.order !== undefined)
|
||||
.map(({ item, payload }) => ({ id: item.id, value: payload.order }));
|
||||
const lessonSectionUpdates = lessonUpdates
|
||||
.filter(({ payload }) => payload.sectionId !== undefined)
|
||||
.map(({ item, payload }) => ({ id: item.id, value: payload.sectionId }));
|
||||
const lessonUnlockUpdates = lessonUpdates
|
||||
.filter(({ payload }) => payload.isUnlocked !== undefined)
|
||||
.map(({ item, payload }) => ({ id: item.id, value: payload.isUnlocked }));
|
||||
|
||||
const lessonSet: Record<string, unknown> = {
|
||||
updatedAt
|
||||
};
|
||||
|
||||
if (lessonOrderUpdates.length > 0) {
|
||||
lessonSet.order = sql`case ${lesson.id} ${sql.join(
|
||||
lessonOrderUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
|
||||
sql` `
|
||||
)} else ${lesson.order} end`;
|
||||
}
|
||||
|
||||
if (lessonSectionUpdates.length > 0) {
|
||||
lessonSet.sectionId = sql`case ${lesson.id} ${sql.join(
|
||||
lessonSectionUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
|
||||
sql` `
|
||||
)} else ${lesson.sectionId} end`;
|
||||
}
|
||||
|
||||
if (lessonUnlockUpdates.length > 0) {
|
||||
lessonSet.isUnlocked = sql`case ${lesson.id} ${sql.join(
|
||||
lessonUnlockUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
|
||||
sql` `
|
||||
)} else ${lesson.isUnlocked} end`;
|
||||
}
|
||||
|
||||
const updatedLessons = await tx
|
||||
.update(lesson)
|
||||
.set(lessonSet)
|
||||
.where(inArray(lesson.id, lessonIds))
|
||||
.returning({ id: lesson.id });
|
||||
|
||||
if (updatedLessons.length !== lessonIds.length) {
|
||||
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
|
||||
}
|
||||
}
|
||||
|
||||
if (exerciseUpdates.length > 0) {
|
||||
const exerciseIds = exerciseUpdates.map(({ item }) => item.id);
|
||||
const exerciseOrderUpdates = exerciseUpdates
|
||||
.filter(({ payload }) => payload.order !== undefined)
|
||||
.map(({ item, payload }) => ({ id: item.id, value: payload.order }));
|
||||
const exerciseSectionUpdates = exerciseUpdates
|
||||
.filter(({ payload }) => payload.sectionId !== undefined)
|
||||
.map(({ item, payload }) => ({ id: item.id, value: payload.sectionId }));
|
||||
const exerciseUnlockUpdates = exerciseUpdates
|
||||
.filter(({ payload }) => payload.isUnlocked !== undefined)
|
||||
.map(({ item, payload }) => ({ id: item.id, value: payload.isUnlocked }));
|
||||
|
||||
const exerciseSet: Record<string, unknown> = {
|
||||
updatedAt
|
||||
};
|
||||
|
||||
if (exerciseOrderUpdates.length > 0) {
|
||||
exerciseSet.order = sql`case ${exercise.id} ${sql.join(
|
||||
exerciseOrderUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
|
||||
sql` `
|
||||
)} else ${exercise.order} end`;
|
||||
}
|
||||
|
||||
if (exerciseSectionUpdates.length > 0) {
|
||||
exerciseSet.sectionId = sql`case ${exercise.id} ${sql.join(
|
||||
exerciseSectionUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
|
||||
sql` `
|
||||
)} else ${exercise.sectionId} end`;
|
||||
}
|
||||
|
||||
if (exerciseUnlockUpdates.length > 0) {
|
||||
exerciseSet.isUnlocked = sql`case ${exercise.id} ${sql.join(
|
||||
exerciseUnlockUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
|
||||
sql` `
|
||||
)} else ${exercise.isUnlocked} end`;
|
||||
}
|
||||
|
||||
const updatedExercises = await tx
|
||||
.update(exercise)
|
||||
.set(exerciseSet)
|
||||
.where(inArray(exercise.id, exerciseIds))
|
||||
.returning({ id: exercise.id });
|
||||
|
||||
if (updatedExercises.length !== exerciseIds.length) {
|
||||
throw new AppError('Exercise not found', ErrorCodes.EXERCISE_NOT_FOUND, 404);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
updatedItems: updates.length,
|
||||
updatedLessons: lessonUpdates.length,
|
||||
updatedExercises: exerciseUpdates.length
|
||||
};
|
||||
}
|
||||
|
||||
async function applySectionOrderUpdates(tx: DbOrTxClient, sections: NonNullable<TCourseContentReorder['sections']>) {
|
||||
if (sections.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const sectionIds = sections.map((section) => section.id);
|
||||
const updatedAt = new Date().toISOString();
|
||||
const sectionSet: Record<string, unknown> = {
|
||||
updatedAt,
|
||||
order: sql`case ${courseSection.id} ${sql.join(
|
||||
sections.map((section) => sql`when ${section.id} then ${section.order}`),
|
||||
sql` `
|
||||
)} else ${courseSection.order} end`
|
||||
};
|
||||
|
||||
const updatedSections = await tx
|
||||
.update(courseSection)
|
||||
.set(sectionSet)
|
||||
.where(inArray(courseSection.id, sectionIds))
|
||||
.returning({ id: courseSection.id });
|
||||
|
||||
if (updatedSections.length !== sectionIds.length) {
|
||||
throw new AppError('Course section not found', ErrorCodes.COURSE_SECTION_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return sections.length;
|
||||
}
|
||||
|
||||
export async function updateCourseContent(courseId: string, items: TCourseContentUpdate['items']) {
|
||||
try {
|
||||
await assertCourseContentItems(courseId, items);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const updates = items
|
||||
.map((item) => ({
|
||||
item,
|
||||
payload: buildUpdatePayload(item)
|
||||
}))
|
||||
.filter(({ payload }) => Object.keys(payload).length > 0);
|
||||
|
||||
if (updates.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedAt = new Date().toISOString();
|
||||
|
||||
const lessonUpdates = updates.filter(({ item }) => item.type === ContentType.Lesson);
|
||||
const exerciseUpdates = updates.filter(({ item }) => item.type === ContentType.Exercise);
|
||||
|
||||
if (lessonUpdates.length > 0) {
|
||||
const lessonIds = lessonUpdates.map(({ item }) => item.id);
|
||||
const lessonOrderUpdates = lessonUpdates
|
||||
.filter(({ payload }) => payload.order !== undefined)
|
||||
.map(({ item, payload }) => ({ id: item.id, value: payload.order }));
|
||||
const lessonSectionUpdates = lessonUpdates
|
||||
.filter(({ payload }) => payload.sectionId !== undefined)
|
||||
.map(({ item, payload }) => ({ id: item.id, value: payload.sectionId }));
|
||||
const lessonUnlockUpdates = lessonUpdates
|
||||
.filter(({ payload }) => payload.isUnlocked !== undefined)
|
||||
.map(({ item, payload }) => ({ id: item.id, value: payload.isUnlocked }));
|
||||
|
||||
const lessonSet: Record<string, unknown> = {
|
||||
updatedAt
|
||||
};
|
||||
|
||||
if (lessonOrderUpdates.length > 0) {
|
||||
lessonSet.order = sql`case ${lesson.id} ${sql.join(
|
||||
lessonOrderUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
|
||||
sql` `
|
||||
)} else ${lesson.order} end`;
|
||||
}
|
||||
|
||||
if (lessonSectionUpdates.length > 0) {
|
||||
lessonSet.sectionId = sql`case ${lesson.id} ${sql.join(
|
||||
lessonSectionUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
|
||||
sql` `
|
||||
)} else ${lesson.sectionId} end`;
|
||||
}
|
||||
|
||||
if (lessonUnlockUpdates.length > 0) {
|
||||
lessonSet.isUnlocked = sql`case ${lesson.id} ${sql.join(
|
||||
lessonUnlockUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
|
||||
sql` `
|
||||
)} else ${lesson.isUnlocked} end`;
|
||||
}
|
||||
|
||||
const updatedLessons = await tx
|
||||
.update(lesson)
|
||||
.set(lessonSet)
|
||||
.where(inArray(lesson.id, lessonIds))
|
||||
.returning({ id: lesson.id });
|
||||
|
||||
if (updatedLessons.length !== lessonIds.length) {
|
||||
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
|
||||
}
|
||||
}
|
||||
|
||||
if (exerciseUpdates.length > 0) {
|
||||
const exerciseIds = exerciseUpdates.map(({ item }) => item.id);
|
||||
const exerciseOrderUpdates = exerciseUpdates
|
||||
.filter(({ payload }) => payload.order !== undefined)
|
||||
.map(({ item, payload }) => ({ id: item.id, value: payload.order }));
|
||||
const exerciseSectionUpdates = exerciseUpdates
|
||||
.filter(({ payload }) => payload.sectionId !== undefined)
|
||||
.map(({ item, payload }) => ({ id: item.id, value: payload.sectionId }));
|
||||
const exerciseUnlockUpdates = exerciseUpdates
|
||||
.filter(({ payload }) => payload.isUnlocked !== undefined)
|
||||
.map(({ item, payload }) => ({ id: item.id, value: payload.isUnlocked }));
|
||||
|
||||
const exerciseSet: Record<string, unknown> = {
|
||||
updatedAt
|
||||
};
|
||||
|
||||
if (exerciseOrderUpdates.length > 0) {
|
||||
exerciseSet.order = sql`case ${exercise.id} ${sql.join(
|
||||
exerciseOrderUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
|
||||
sql` `
|
||||
)} else ${exercise.order} end`;
|
||||
}
|
||||
|
||||
if (exerciseSectionUpdates.length > 0) {
|
||||
exerciseSet.sectionId = sql`case ${exercise.id} ${sql.join(
|
||||
exerciseSectionUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
|
||||
sql` `
|
||||
)} else ${exercise.sectionId} end`;
|
||||
}
|
||||
|
||||
if (exerciseUnlockUpdates.length > 0) {
|
||||
exerciseSet.isUnlocked = sql`case ${exercise.id} ${sql.join(
|
||||
exerciseUnlockUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
|
||||
sql` `
|
||||
)} else ${exercise.isUnlocked} end`;
|
||||
}
|
||||
|
||||
const updatedExercises = await tx
|
||||
.update(exercise)
|
||||
.set(exerciseSet)
|
||||
.where(inArray(exercise.id, exerciseIds))
|
||||
.returning({ id: exercise.id });
|
||||
|
||||
if (updatedExercises.length !== exerciseIds.length) {
|
||||
throw new AppError('Exercise not found', ErrorCodes.EXERCISE_NOT_FOUND, 404);
|
||||
}
|
||||
}
|
||||
await applyCourseContentUpdates(tx, items);
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
@@ -194,6 +255,48 @@ export async function updateCourseContent(courseId: string, items: TCourseConten
|
||||
}
|
||||
}
|
||||
|
||||
export async function reorderCourseContent(
|
||||
courseId: string,
|
||||
payload: TCourseContentReorder
|
||||
): Promise<CourseContentReorderResult> {
|
||||
try {
|
||||
if (payload.sections?.length) {
|
||||
await assertCourseSections(courseId, payload.sections);
|
||||
}
|
||||
|
||||
if (payload.items?.length) {
|
||||
await assertCourseContentItems(courseId, payload.items);
|
||||
}
|
||||
|
||||
return await db.transaction(async (tx) => {
|
||||
const updatedSections = payload.sections?.length ? await applySectionOrderUpdates(tx, payload.sections) : 0;
|
||||
const itemResult = payload.items?.length
|
||||
? await applyCourseContentUpdates(tx, payload.items)
|
||||
: {
|
||||
updatedItems: 0,
|
||||
updatedLessons: 0,
|
||||
updatedExercises: 0
|
||||
};
|
||||
|
||||
return {
|
||||
courseId,
|
||||
updatedSections,
|
||||
...itemResult
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to reorder course content',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCourseContent(courseId: string, payload: TCourseContentDelete) {
|
||||
try {
|
||||
if (payload.sectionId) {
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import * as schema from '@cio/db/schema';
|
||||
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import type { TCourse, TOrganization } from '@db/types';
|
||||
import type { TCourseLandingPageMetadataUpdate, TCourseLandingPageUpdate } from '@cio/utils/validation/course';
|
||||
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import { env } from '@api/config/env';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { generateSlug } from '@cio/utils/functions';
|
||||
import { getCourseById } from '@cio/db/queries/course';
|
||||
import { getCourseOrganizationId } from '@cio/db/queries/tag';
|
||||
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
||||
import { getOrganizationById } from '@cio/db/queries/organization';
|
||||
import { updateCourse } from '@api/services/course/course';
|
||||
|
||||
type CourseLandingPageUpdateResult = {
|
||||
course: TCourse;
|
||||
courseUrl: string;
|
||||
bannerImageUrl: string | null;
|
||||
};
|
||||
|
||||
async function generateUniqueCourseSlug(baseSlug: string): Promise<string> {
|
||||
let suffix = 0;
|
||||
let candidate = baseSlug;
|
||||
|
||||
while (true) {
|
||||
const existing = await db
|
||||
.select({ id: schema.course.id })
|
||||
.from(schema.course)
|
||||
.where(eq(schema.course.slug, candidate))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
suffix += 1;
|
||||
candidate = `${baseSlug}-${suffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCourseSlug(courseId: string, title: string | null | undefined): Promise<string> {
|
||||
const [course] = await getCourseById(courseId);
|
||||
if (course?.slug) {
|
||||
return course.slug;
|
||||
}
|
||||
|
||||
const baseSlug = generateSlug(title, { fallback: 'course' });
|
||||
const uniqueSlug = await generateUniqueCourseSlug(baseSlug);
|
||||
await updateCourse(courseId, { slug: uniqueSlug });
|
||||
return uniqueSlug;
|
||||
}
|
||||
|
||||
function buildCourseBaseUrl(organization: TOrganization) {
|
||||
if (organization.customDomain && organization.isCustomDomainVerified) {
|
||||
return `https://${organization.customDomain}`;
|
||||
}
|
||||
|
||||
return getDashboardBaseUrl(organization.siteName ?? undefined);
|
||||
}
|
||||
|
||||
async function resolveCourseUrl(organizationId: string, courseId: string, title: string) {
|
||||
const organization = await getOrganizationById(organizationId);
|
||||
if (!organization) {
|
||||
throw new AppError('Organization not found', ErrorCodes.ORG_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const slug = await ensureCourseSlug(courseId, title);
|
||||
return `${buildCourseBaseUrl(organization)}/course/${encodeURIComponent(slug)}`;
|
||||
}
|
||||
|
||||
async function resolveUnsplashBannerImage(courseTitle: string, query?: string) {
|
||||
if (!env.UNSPLASH_API_KEY) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const searchQuery = (query?.trim() || courseTitle.trim() || 'education').slice(0, 120);
|
||||
const response = await fetch(
|
||||
`https://api.unsplash.com/search/photos?page=1&per_page=15&auto=format&fit=crop&w=2970&q=80&client_id=${env.UNSPLASH_API_KEY}&query=${encodeURIComponent(searchQuery)}`,
|
||||
{ method: 'GET' }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
results?: Array<{
|
||||
urls?: {
|
||||
regular?: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
const photos = data.results ?? [];
|
||||
if (photos.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const photo = photos[Math.floor(Math.random() * photos.length)];
|
||||
return photo?.urls?.regular ?? null;
|
||||
}
|
||||
|
||||
async function resolveLandingPageImage(
|
||||
courseTitle: string,
|
||||
payload: Pick<TCourseLandingPageUpdate, 'generateImage' | 'imageQuery' | 'imageUrl'>
|
||||
) {
|
||||
if (payload.imageUrl) {
|
||||
return payload.imageUrl;
|
||||
}
|
||||
|
||||
if (!payload.generateImage && !payload.imageQuery) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await resolveUnsplashBannerImage(courseTitle, payload.imageQuery);
|
||||
} catch (error) {
|
||||
console.error('resolveLandingPageImage error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mergeLandingPageMetadata(
|
||||
existing: TCourse['metadata'] | null | undefined,
|
||||
update: TCourseLandingPageMetadataUpdate | undefined
|
||||
): TCourse['metadata'] | undefined {
|
||||
if (!update) {
|
||||
return existing ?? undefined;
|
||||
}
|
||||
|
||||
const current: NonNullable<TCourse['metadata']> = {
|
||||
goals: '',
|
||||
description: '',
|
||||
requirements: '',
|
||||
allowNewStudent: true,
|
||||
...(existing ?? {})
|
||||
};
|
||||
|
||||
return {
|
||||
...current,
|
||||
...update,
|
||||
reward: update.reward
|
||||
? {
|
||||
show: update.reward.show ?? current.reward?.show ?? false,
|
||||
description: update.reward.description ?? current.reward?.description ?? ''
|
||||
}
|
||||
: current.reward,
|
||||
instructor: update.instructor
|
||||
? {
|
||||
name: update.instructor.name ?? current.instructor?.name ?? '',
|
||||
role: update.instructor.role ?? current.instructor?.role ?? '',
|
||||
coursesNo: update.instructor.coursesNo ?? current.instructor?.coursesNo ?? 0,
|
||||
description: update.instructor.description ?? current.instructor?.description ?? '',
|
||||
imgUrl: update.instructor.imgUrl ?? current.instructor?.imgUrl ?? ''
|
||||
}
|
||||
: current.instructor,
|
||||
certificate: update.certificate
|
||||
? {
|
||||
templateUrl: update.certificate.templateUrl ?? current.certificate?.templateUrl ?? ''
|
||||
}
|
||||
: current.certificate,
|
||||
sectionDisplay: update.sectionDisplay
|
||||
? { ...(current.sectionDisplay ?? {}), ...update.sectionDisplay }
|
||||
: current.sectionDisplay,
|
||||
reviews: update.reviews ?? current.reviews,
|
||||
lessonTabsOrder: update.lessonTabsOrder ?? current.lessonTabsOrder
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeReviews(reviews: NonNullable<TCourse['metadata']>['reviews'] | undefined) {
|
||||
return reviews?.map((review, index) => ({
|
||||
id: review.id ?? Date.now() + index,
|
||||
hide: review.hide ?? false,
|
||||
name: review.name,
|
||||
avatar_url: review.avatar_url,
|
||||
rating: review.rating,
|
||||
created_at: review.created_at ?? Date.now(),
|
||||
description: review.description
|
||||
}));
|
||||
}
|
||||
|
||||
export async function updateCourseLandingPageService(
|
||||
courseId: string,
|
||||
payload: TCourseLandingPageUpdate
|
||||
): Promise<CourseLandingPageUpdateResult> {
|
||||
const [existingCourse] = await getCourseById(courseId);
|
||||
if (!existingCourse) {
|
||||
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const nextTitle = payload.title ?? existingCourse.title;
|
||||
const metadata = mergeLandingPageMetadata(existingCourse.metadata ?? undefined, payload.metadata);
|
||||
const imageUrl = await resolveLandingPageImage(nextTitle, payload);
|
||||
|
||||
const updatedCourse = await updateCourse(courseId, {
|
||||
title: payload.title,
|
||||
description: payload.description,
|
||||
overview: payload.overview,
|
||||
cost: payload.cost,
|
||||
currency: payload.currency,
|
||||
metadata: metadata
|
||||
? {
|
||||
...metadata,
|
||||
reviews: normalizeReviews(metadata.reviews)
|
||||
}
|
||||
: undefined,
|
||||
...(imageUrl ? { logo: imageUrl, bannerImage: imageUrl } : {})
|
||||
});
|
||||
|
||||
const organizationId = await getCourseOrganizationId(courseId);
|
||||
if (!organizationId) {
|
||||
throw new AppError('Course organization not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const courseUrl = await resolveCourseUrl(organizationId, courseId, updatedCourse.title);
|
||||
|
||||
return {
|
||||
course: updatedCourse,
|
||||
courseUrl,
|
||||
bannerImageUrl: updatedCourse.logo || imageUrl || null
|
||||
};
|
||||
}
|
||||
@@ -15,13 +15,19 @@ import {
|
||||
listOrganizationApiKeys,
|
||||
updateOrganizationApiKey
|
||||
} from '@cio/db/queries/organization';
|
||||
import { assertOrganizationAutomationKeyCreationAllowed } from './automation-usage';
|
||||
|
||||
const DEFAULT_SCOPES: Record<TOrganizationApiKeyType, TOrganizationApiKeyScope[]> = {
|
||||
mcp: [
|
||||
'course_import:draft:create',
|
||||
'course_import:draft:read',
|
||||
'course_import:draft:update',
|
||||
'course_import:draft:publish'
|
||||
'course_import:draft:publish',
|
||||
'course:read',
|
||||
'course:write',
|
||||
'course:tag:write',
|
||||
'course:exercise:read',
|
||||
'course:exercise:write'
|
||||
],
|
||||
api: ['public_api:*'],
|
||||
zapier: ['public_api:*']
|
||||
@@ -86,6 +92,8 @@ export async function createOrganizationApiKeyService(
|
||||
createdByProfileId: string,
|
||||
payload: TCreateOrganizationApiKey
|
||||
): Promise<{ key: OrganizationApiKeySummary; secret: string }> {
|
||||
await assertOrganizationAutomationKeyCreationAllowed(organizationId, payload.type);
|
||||
|
||||
const secret = generateOrganizationApiKeySecret(payload.type);
|
||||
const key = await createOrganizationApiKey({
|
||||
organizationId,
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import {
|
||||
countActiveOrganizationApiKeys,
|
||||
countOrganizationAutomationUsageSince,
|
||||
countOrganizationAutomationUsageSinceByKey,
|
||||
createOrganizationAutomationUsage,
|
||||
getActiveOrganizationPlan,
|
||||
getOrganizationAutomationCreditsUsedSince,
|
||||
listRecentOrganizationAutomationUsage
|
||||
} from '@cio/db/queries/organization';
|
||||
import type { TOrganizationApiKey, TOrganizationApiKeyType, TPlan } from '@db/types';
|
||||
import {
|
||||
AUTOMATION_TYPE,
|
||||
getMcpAutomationCategory,
|
||||
getMcpAutomationLimits,
|
||||
MCP_TOOL_CREDIT_COST,
|
||||
type TMcpToolName
|
||||
} from '@cio/utils/plans';
|
||||
|
||||
export type OrganizationAutomationUsageSummary = {
|
||||
type: TOrganizationApiKeyType;
|
||||
planName: TPlan;
|
||||
billingPeriodStart: string;
|
||||
billingPeriodEnd: string;
|
||||
monthlyCreditsIncluded: number;
|
||||
monthlyCreditsUsed: number;
|
||||
monthlyCreditsRemaining: number;
|
||||
activeKeys: number;
|
||||
maxActiveKeys: number;
|
||||
rateLimits: ReturnType<typeof getMcpAutomationLimits>['rateLimits'];
|
||||
recentActions: Array<{
|
||||
action: string;
|
||||
creditsConsumed: number;
|
||||
createdAt: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
function getBillingPeriodStart(date: Date = new Date()) {
|
||||
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1, 0, 0, 0, 0));
|
||||
}
|
||||
|
||||
function getBillingPeriodEnd(date: Date = new Date()) {
|
||||
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1, 0, 0, 0, 0));
|
||||
}
|
||||
|
||||
function getMinuteWindowStart(date: Date = new Date()) {
|
||||
return new Date(date.getTime() - 60_000);
|
||||
}
|
||||
|
||||
async function getOrganizationPlanName(orgId: string): Promise<TPlan> {
|
||||
const activePlan = await getActiveOrganizationPlan(orgId);
|
||||
return (activePlan?.planName as TPlan | null) ?? 'BASIC';
|
||||
}
|
||||
|
||||
export async function assertOrganizationAutomationKeyCreationAllowed(
|
||||
organizationId: string,
|
||||
type: TOrganizationApiKeyType
|
||||
): Promise<void> {
|
||||
if (type !== AUTOMATION_TYPE.MCP) {
|
||||
return;
|
||||
}
|
||||
|
||||
const planName = await getOrganizationPlanName(organizationId);
|
||||
const limits = getMcpAutomationLimits(planName);
|
||||
const activeKeys = await countActiveOrganizationApiKeys(organizationId, type);
|
||||
|
||||
if (activeKeys >= limits.maxActiveKeys) {
|
||||
throw new AppError(
|
||||
`Your plan allows up to ${limits.maxActiveKeys} active MCP key${limits.maxActiveKeys === 1 ? '' : 's'}`,
|
||||
ErrorCodes.AUTOMATION_KEY_LIMIT_EXCEEDED,
|
||||
403
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOrganizationAutomationUsageSummaryService(
|
||||
organizationId: string,
|
||||
type: TOrganizationApiKeyType
|
||||
): Promise<OrganizationAutomationUsageSummary> {
|
||||
const planName = await getOrganizationPlanName(organizationId);
|
||||
const billingPeriodStart = getBillingPeriodStart();
|
||||
const billingPeriodEnd = getBillingPeriodEnd();
|
||||
|
||||
if (type !== AUTOMATION_TYPE.MCP) {
|
||||
return {
|
||||
type,
|
||||
planName,
|
||||
billingPeriodStart: billingPeriodStart.toISOString(),
|
||||
billingPeriodEnd: billingPeriodEnd.toISOString(),
|
||||
monthlyCreditsIncluded: 0,
|
||||
monthlyCreditsUsed: 0,
|
||||
monthlyCreditsRemaining: 0,
|
||||
activeKeys: await countActiveOrganizationApiKeys(organizationId, type),
|
||||
maxActiveKeys: 0,
|
||||
rateLimits: {
|
||||
perKey: { readPerMinute: 0, writePerMinute: 0, publishPerMinute: 0 },
|
||||
perOrg: { readPerMinute: 0, writePerMinute: 0, publishPerMinute: 0 }
|
||||
},
|
||||
recentActions: []
|
||||
};
|
||||
}
|
||||
|
||||
const limits = getMcpAutomationLimits(planName);
|
||||
const [monthlyCreditsUsed, activeKeys, recentActions] = await Promise.all([
|
||||
getOrganizationAutomationCreditsUsedSince(organizationId, type, billingPeriodStart.toISOString()),
|
||||
countActiveOrganizationApiKeys(organizationId, type),
|
||||
listRecentOrganizationAutomationUsage(organizationId, type, 10)
|
||||
]);
|
||||
|
||||
return {
|
||||
type,
|
||||
planName,
|
||||
billingPeriodStart: billingPeriodStart.toISOString(),
|
||||
billingPeriodEnd: billingPeriodEnd.toISOString(),
|
||||
monthlyCreditsIncluded: limits.monthlyCredits,
|
||||
monthlyCreditsUsed,
|
||||
monthlyCreditsRemaining: Math.max(limits.monthlyCredits - monthlyCreditsUsed, 0),
|
||||
activeKeys,
|
||||
maxActiveKeys: limits.maxActiveKeys,
|
||||
rateLimits: limits.rateLimits,
|
||||
recentActions: recentActions.map((action) => ({
|
||||
action: action.action,
|
||||
creditsConsumed: action.creditsConsumed,
|
||||
createdAt: action.createdAt
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
export async function assertMcpAutomationUsageAllowed(
|
||||
automationKey: TOrganizationApiKey,
|
||||
toolName: TMcpToolName
|
||||
): Promise<void> {
|
||||
const planName = await getOrganizationPlanName(automationKey.organizationId);
|
||||
const limits = getMcpAutomationLimits(planName);
|
||||
const category = getMcpAutomationCategory(toolName);
|
||||
const now = new Date();
|
||||
const billingPeriodStart = getBillingPeriodStart(now).toISOString();
|
||||
const minuteWindowStart = getMinuteWindowStart(now).toISOString();
|
||||
|
||||
const [creditsUsed, keyRequestsInWindow, orgRequestsInWindow] = await Promise.all([
|
||||
getOrganizationAutomationCreditsUsedSince(automationKey.organizationId, automationKey.type, billingPeriodStart),
|
||||
countOrganizationAutomationUsageSinceByKey(automationKey.id, category, minuteWindowStart),
|
||||
countOrganizationAutomationUsageSince(automationKey.organizationId, automationKey.type, category, minuteWindowStart)
|
||||
]);
|
||||
|
||||
const creditsNeeded = MCP_TOOL_CREDIT_COST[toolName];
|
||||
if (creditsUsed + creditsNeeded > limits.monthlyCredits) {
|
||||
throw new AppError(
|
||||
'Monthly automation credits exhausted for this organization',
|
||||
ErrorCodes.AUTOMATION_CREDIT_LIMIT_EXCEEDED,
|
||||
429
|
||||
);
|
||||
}
|
||||
|
||||
const keyLimit =
|
||||
category === 'read'
|
||||
? limits.rateLimits.perKey.readPerMinute
|
||||
: category === 'write'
|
||||
? limits.rateLimits.perKey.writePerMinute
|
||||
: limits.rateLimits.perKey.publishPerMinute;
|
||||
|
||||
const orgLimit =
|
||||
category === 'read'
|
||||
? limits.rateLimits.perOrg.readPerMinute
|
||||
: category === 'write'
|
||||
? limits.rateLimits.perOrg.writePerMinute
|
||||
: limits.rateLimits.perOrg.publishPerMinute;
|
||||
|
||||
if (keyRequestsInWindow >= keyLimit || orgRequestsInWindow >= orgLimit) {
|
||||
throw new AppError('Automation rate limit exceeded', ErrorCodes.AUTOMATION_RATE_LIMIT_EXCEEDED, 429);
|
||||
}
|
||||
}
|
||||
|
||||
export async function recordMcpAutomationUsage(
|
||||
automationKey: TOrganizationApiKey,
|
||||
toolName: TMcpToolName,
|
||||
metadata: Record<string, unknown> = {}
|
||||
): Promise<void> {
|
||||
await createOrganizationAutomationUsage({
|
||||
organizationId: automationKey.organizationId,
|
||||
organizationApiKeyId: automationKey.id,
|
||||
type: automationKey.type,
|
||||
action: toolName,
|
||||
category: getMcpAutomationCategory(toolName),
|
||||
creditsConsumed: MCP_TOOL_CREDIT_COST[toolName],
|
||||
metadata
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import type {
|
||||
TAutomationCourseTagAssignment,
|
||||
TAutomationDraftTagAssignment,
|
||||
TCourseTagAssignment,
|
||||
TTagCreate,
|
||||
TTagGroupCreate,
|
||||
@@ -8,6 +10,8 @@ import type {
|
||||
import {
|
||||
createTag,
|
||||
createTagGroup,
|
||||
findTagBySlug,
|
||||
findTagGroupBySlug,
|
||||
generateUniqueTagGroupSlug,
|
||||
generateUniqueTagSlug,
|
||||
getCourseOrganizationId,
|
||||
@@ -21,6 +25,8 @@ import {
|
||||
updateTagGroup
|
||||
} from '@cio/db/queries/tag';
|
||||
import { getOrgIdBySiteName, isUserOrgAdmin } from '@cio/db/queries/organization';
|
||||
import { TAG_COLOR_OPTIONS, type TTagColor } from '@cio/utils/validation/tag';
|
||||
import { slugifyTagValue } from '@cio/db/queries/tag';
|
||||
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
|
||||
@@ -34,6 +40,51 @@ function normalizeOptionalText(value: string | undefined): string | null | undef
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeAutomationTagNames(tagNames: string[]) {
|
||||
return Array.from(new Map(tagNames.map((name) => [name.trim().toLowerCase(), name.trim()])).values()).filter(Boolean);
|
||||
}
|
||||
|
||||
function pickTagColor(name: string): TTagColor {
|
||||
const seed = Array.from(name).reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||||
return TAG_COLOR_OPTIONS[seed % TAG_COLOR_OPTIONS.length] as TTagColor;
|
||||
}
|
||||
|
||||
async function ensureAutomationTagGroup(orgId: string, groupName: string) {
|
||||
const slug = slugifyTagValue(groupName);
|
||||
const existing = await findTagGroupBySlug(orgId, slug);
|
||||
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
return createOrganizationTagGroup(orgId, { name: groupName });
|
||||
}
|
||||
|
||||
async function ensureAutomationTags(orgId: string, tagNames: string[], groupName: string) {
|
||||
const normalizedTagNames = normalizeAutomationTagNames(tagNames);
|
||||
const group = await ensureAutomationTagGroup(orgId, groupName);
|
||||
const tags = [];
|
||||
|
||||
for (const tagName of normalizedTagNames) {
|
||||
const slug = slugifyTagValue(tagName);
|
||||
const existing = await findTagBySlug(orgId, slug);
|
||||
|
||||
if (existing) {
|
||||
tags.push(existing);
|
||||
continue;
|
||||
}
|
||||
|
||||
const created = await createOrganizationTag(orgId, {
|
||||
groupId: group.id,
|
||||
name: tagName,
|
||||
color: pickTagColor(tagName)
|
||||
});
|
||||
tags.push(created);
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
export async function getOrganizationTagGroups(orgId: string) {
|
||||
try {
|
||||
return getTagGroupsWithTags(orgId);
|
||||
@@ -301,3 +352,23 @@ export async function replaceCourseTags(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function assignCourseTagsByName(orgId: string, courseId: string, data: TAutomationDraftTagAssignment) {
|
||||
const ensuredTags = await ensureAutomationTags(orgId, data.tagNames, data.groupName?.trim() || 'Automation');
|
||||
const existingTags = data.mode === 'merge' ? await getCourseTags(orgId, courseId) : [];
|
||||
const tagIds = Array.from(new Set([...existingTags.map((tag) => tag.id), ...ensuredTags.map((tag) => tag.id)]));
|
||||
|
||||
return replaceCourseTags(orgId, courseId, { tagIds });
|
||||
}
|
||||
|
||||
export async function assignTagsToCoursesByName(orgId: string, data: TAutomationCourseTagAssignment) {
|
||||
const normalizedCourseIds = Array.from(new Set(data.courseIds));
|
||||
const results = [];
|
||||
|
||||
for (const courseId of normalizedCourseIds) {
|
||||
const tags = await assignCourseTagsByName(orgId, courseId, data);
|
||||
results.push({ courseId, tags });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -67,6 +67,9 @@ export const ErrorCodes = {
|
||||
ORGANIZATION_API_KEY_FETCH_FAILED: 'ORGANIZATION_API_KEY_FETCH_FAILED',
|
||||
ORGANIZATION_API_KEY_REVOKE_FAILED: 'ORGANIZATION_API_KEY_REVOKE_FAILED',
|
||||
ORGANIZATION_API_KEY_ROTATE_FAILED: 'ORGANIZATION_API_KEY_ROTATE_FAILED',
|
||||
AUTOMATION_KEY_LIMIT_EXCEEDED: 'AUTOMATION_KEY_LIMIT_EXCEEDED',
|
||||
AUTOMATION_CREDIT_LIMIT_EXCEEDED: 'AUTOMATION_CREDIT_LIMIT_EXCEEDED',
|
||||
AUTOMATION_RATE_LIMIT_EXCEEDED: 'AUTOMATION_RATE_LIMIT_EXCEEDED',
|
||||
TAG_GROUP_NOT_FOUND: 'TAG_GROUP_NOT_FOUND',
|
||||
TAG_NOT_FOUND: 'TAG_NOT_FOUND',
|
||||
TAG_FETCH_FAILED: 'TAG_FETCH_FAILED',
|
||||
|
||||
Reference in New Issue
Block a user