feat: implement automation feature

This commit is contained in:
Rotimi Best
2026-03-18 23:39:12 +01:00
parent 576d0366de
commit ef4424e267
121 changed files with 4544 additions and 1055 deletions
@@ -1,22 +1,37 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { env } from '@api/config/env';
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { createCourse, updateCourse } from '@api/services/course/course';
import { createCourseSection, listCourseSections, updateCourseSectionService } from '@api/services/course/section';
import { createLesson, listLessons, updateLessonService } from '@api/services/lesson';
import { upsertLessonLanguageService, updateLessonLanguageService } from '@api/services/lesson-language';
import {
createCourseImportDraft,
getCourseImportDraftById,
getCourseImportDraftByIdempotencyKey,
updateCourseImportDraft
} from '@cio/db/queries/course-import';
import type { TCourseImportDraft, TLocale } from '@db/types';
import type {
TCourseImportDraftCreate,
TCourseImportDraftPayload,
TCourseImportDraftPublish,
TCourseImportDraftUpdate
import { getCourseById } from '@cio/db/queries/course';
import { getLessonLanguagesByLessonIds } from '@cio/db/queries/lesson';
import { getOrganizationById } from '@cio/db/queries/organization';
import { getCourseTagsForOrganization } from '@cio/db/queries/tag';
import type { TCourse, TCourseImportDraft, TLessonLanguage, TLocale, TOrganization } from '@db/types';
import {
type TCourseImportDraftCreate,
type TCourseImportDraftCreateFromCourse,
type TCourseImportDraftPayload,
type TCourseImportDraftPublish,
type TCourseImportDraftPublishToCourse,
type TCourseImportDraftUpdate,
ZCourseImportDraftPayload,
ZCourseImportWarning
} from '@cio/utils/validation/course-import';
import { ZCourseImportDraftPayload } from '@cio/utils/validation/course-import';
import { createCourse, updateCourse } from '@api/services/course/course';
import { createCourseSection } from '@api/services/course/section';
import { createLesson } from '@api/services/lesson';
import { upsertLessonLanguageService } from '@api/services/lesson-language';
import { getCourseOrganizationId } from '@cio/db/queries/tag';
import { assignCourseTagsByName } from '@api/services/tag';
import { db } from '@cio/db/drizzle';
import * as schema from '@cio/db/schema';
import { generateSlug } from '@cio/utils/functions';
import { eq } from 'drizzle-orm';
type DraftSummary = {
sectionCount: number;
@@ -25,6 +40,30 @@ type DraftSummary = {
warningCount: number;
};
type CourseStructureSnapshot = {
courseId: string;
draft: TCourseImportDraftPayload;
};
type PublishDraftResult = {
courseId: string;
courseUrl: string;
bannerImageUrl: string | null;
tagNames: string[];
createdSections: number;
createdLessons: number;
localeCount: number;
};
type PublishDraftToExistingCourseResult = PublishDraftResult & {
updatedSections: number;
updatedLessons: number;
createdLessonLanguages: number;
updatedLessonLanguages: number;
untouchedSections: number;
untouchedLessons: number;
};
function buildDraftSummary(draft: TCourseImportDraftPayload): DraftSummary {
return {
sectionCount: draft.sections.length,
@@ -34,10 +73,314 @@ function buildDraftSummary(draft: TCourseImportDraftPayload): DraftSummary {
};
}
async function generateUniqueCourseSlug(baseSlug: string): Promise<string> {
let suffix = 0;
let candidate = baseSlug;
while (true) {
const existing = await db
.select({ id: schema.course.id })
.from(schema.course)
.where(eq(schema.course.slug, candidate))
.limit(1);
if (existing.length === 0) {
return candidate;
}
suffix += 1;
candidate = `${baseSlug}-${suffix}`;
}
}
async function ensureCourseSlug(courseId: string, title: string | null | undefined): Promise<string> {
const [course] = await getCourseById(courseId);
if (course?.slug) {
return course.slug;
}
const baseSlug = generateSlug(title, { fallback: 'course' });
const uniqueSlug = await generateUniqueCourseSlug(baseSlug);
await updateCourse(courseId, { slug: uniqueSlug });
return uniqueSlug;
}
function buildCourseBaseUrl(organization: TOrganization) {
if (organization.customDomain && organization.isCustomDomainVerified) {
return `https://${organization.customDomain}`;
}
return getDashboardBaseUrl(organization.siteName ?? undefined);
}
async function resolveCourseUrl(organizationId: string, courseId: string, title: string) {
const organization = await getOrganizationById(organizationId);
if (!organization) {
throw new AppError('Organization not found', ErrorCodes.ORG_NOT_FOUND, 404);
}
const slug = await ensureCourseSlug(courseId, title);
return `${buildCourseBaseUrl(organization)}/course/${encodeURIComponent(slug)}`;
}
async function resolveUnsplashBannerImage(courseTitle: string, query?: string) {
if (!env.UNSPLASH_API_KEY) {
return null;
}
const searchQuery = (query?.trim() || courseTitle.trim() || 'education').slice(0, 120);
const response = await fetch(
`https://api.unsplash.com/search/photos?page=1&per_page=15&auto=format&fit=crop&w=2970&q=80&client_id=${env.UNSPLASH_API_KEY}&query=${encodeURIComponent(searchQuery)}`,
{ method: 'GET' }
);
if (!response.ok) {
return null;
}
const data = (await response.json()) as {
results?: Array<{
urls?: {
regular?: string;
};
}>;
};
const photos = data.results ?? [];
if (photos.length === 0) {
return null;
}
const photo = photos[Math.floor(Math.random() * photos.length)];
return photo?.urls?.regular ?? null;
}
async function resolvePublishBannerImage(
title: string,
overrides: Pick<TCourseImportDraftPublish, 'bannerImageQuery' | 'bannerImageUrl' | 'generateBannerImage'>
) {
if (overrides.bannerImageUrl) {
return overrides.bannerImageUrl;
}
if (!overrides.generateBannerImage && !overrides.bannerImageQuery) {
return null;
}
try {
return await resolveUnsplashBannerImage(title, overrides.bannerImageQuery);
} catch (error) {
console.error('resolvePublishBannerImage error:', error);
return null;
}
}
async function maybeApplyCourseBannerImage(courseId: string, imageUrl: string | null) {
if (!imageUrl) {
return null;
}
await updateCourse(courseId, {
logo: imageUrl,
bannerImage: imageUrl
});
return imageUrl;
}
function normalizeDraftTagNames(tagNames: string[]) {
return Array.from(new Map(tagNames.map((tag) => [tag.trim().toLowerCase(), tag.trim()])).values()).filter(Boolean);
}
function parseStoredDraft(record: TCourseImportDraft): TCourseImportDraftPayload {
return ZCourseImportDraftPayload.parse(record.draft);
}
async function assertCourseBelongsToOrganization(orgId: string, courseId: string) {
const courseOrgId = await getCourseOrganizationId(courseId);
if (!courseOrgId) {
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
}
if (courseOrgId !== orgId) {
throw new AppError('Invalid permissions', ErrorCodes.UNAUTHORIZED, 403);
}
}
async function getCourseRecord(orgId: string, courseId: string): Promise<TCourse> {
await assertCourseBelongsToOrganization(orgId, courseId);
const [course] = await getCourseById(courseId);
if (!course) {
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
}
return course;
}
function inferDraftLocale(languages: TLessonLanguage[]): TLocale {
const locale = languages[0]?.locale;
return (locale ?? 'en') as TLocale;
}
function getPrimaryLessonContent(
lessonExternalId: string,
lessonLanguages: TCourseImportDraftPayload['lessonLanguages'],
preferredLocale: TLocale
) {
const localized = lessonLanguages.find(
(lessonLanguage) =>
lessonLanguage.lessonExternalId === lessonExternalId && lessonLanguage.locale === preferredLocale
);
if (localized) {
return localized.content;
}
return lessonLanguages.find((lessonLanguage) => lessonLanguage.lessonExternalId === lessonExternalId)?.content;
}
async function buildCourseStructureSnapshot(orgId: string, courseId: string): Promise<CourseStructureSnapshot> {
const course = await getCourseRecord(orgId, courseId);
const [sections, lessons, tags] = await Promise.all([
listCourseSections(courseId),
listLessons(courseId),
getCourseTagsForOrganization(orgId, courseId)
]);
if (lessons.length === 0) {
throw new AppError(
'Course must contain at least one lesson before it can be imported into a draft',
ErrorCodes.VALIDATION_ERROR,
400
);
}
const lessonIds = lessons.map((lesson) => lesson.id);
const lessonLanguages = await getLessonLanguagesByLessonIds(lessonIds);
const fallbackLocale = inferDraftLocale(lessonLanguages);
const warnings: TCourseImportDraftPayload['warnings'] = [];
const normalizedSections = [...sections]
.sort((a, b) => Number(a.order ?? 0) - Number(b.order ?? 0))
.map((section) => ({
externalId: section.id,
title: section.title ?? 'Untitled Section',
order: Number(section.order ?? 0)
}));
let sectionReferenceMap = new Map(normalizedSections.map((section) => [section.externalId, section.externalId]));
if (normalizedSections.length === 0 || lessons.some((lesson) => !lesson.sectionId)) {
const syntheticSectionId = `synthetic:${courseId}:ungrouped`;
normalizedSections.push({
externalId: syntheticSectionId,
title: 'Ungrouped',
order: normalizedSections.length
});
sectionReferenceMap = new Map(normalizedSections.map((section) => [section.externalId, section.externalId]));
warnings.push(
ZCourseImportWarning.parse({
code: 'UNGROUPED_CONTENT_NORMALIZED',
message: 'Some lessons were ungrouped. A synthetic section was added to the draft.',
severity: 'warning'
})
);
}
const normalizedLessons = [...lessons]
.sort((a, b) => Number(a.order ?? 0) - Number(b.order ?? 0))
.map((lesson) => ({
externalId: lesson.id,
sectionExternalId:
lesson.sectionId && sectionReferenceMap.has(lesson.sectionId)
? lesson.sectionId
: normalizedSections[normalizedSections.length - 1]!.externalId,
title: lesson.title,
order: Number(lesson.order ?? 0),
isUnlocked: lesson.isUnlocked ?? undefined,
public: lesson.public ?? undefined
}));
const normalizedLessonLanguages: TCourseImportDraftPayload['lessonLanguages'] = [];
for (const lesson of lessons) {
const localized = lessonLanguages.filter((lessonLanguage) => lessonLanguage.lessonId === lesson.id);
if (localized.length > 0) {
const validLocalized = localized
.map((lessonLanguage) => ({
lessonExternalId: lesson.id,
locale: lessonLanguage.locale as TLocale,
content: lessonLanguage.content?.trim() ?? ''
}))
.filter((lessonLanguage) => lessonLanguage.content.length > 0);
if (validLocalized.length > 0) {
normalizedLessonLanguages.push(...validLocalized);
continue;
}
warnings.push(
ZCourseImportWarning.parse({
code: 'EMPTY_LOCALE_CONTENT_SKIPPED',
message: `Lesson "${lesson.title}" had empty localized content and required a fallback.`,
severity: 'warning'
})
);
}
if (lesson.note?.trim()) {
normalizedLessonLanguages.push({
lessonExternalId: lesson.id,
locale: fallbackLocale,
content: lesson.note
});
warnings.push(
ZCourseImportWarning.parse({
code: 'LEGACY_NOTE_FALLBACK',
message: `Lesson "${lesson.title}" used legacy note content because no lesson_language rows were found.`,
severity: 'info'
})
);
}
}
if (normalizedLessonLanguages.length === 0) {
throw new AppError(
'Course lessons must contain note or localized content before they can be imported into a draft',
ErrorCodes.VALIDATION_ERROR,
400
);
}
const draft = ZCourseImportDraftPayload.parse({
course: {
title: course.title,
description: course.description,
type: course.type,
locale: fallbackLocale,
metadata: course.metadata
},
tags: tags.map((tag) => tag.name),
sections: normalizedSections,
lessons: normalizedLessons,
lessonLanguages: normalizedLessonLanguages,
sourceReferences: [
{
type: 'course',
label: course.title
}
],
warnings
});
return {
courseId,
draft
};
}
export async function createCourseImportDraftService(orgId: string, profileId: string, data: TCourseImportDraftCreate) {
try {
if (data.idempotencyKey) {
@@ -76,6 +419,48 @@ export async function createCourseImportDraftService(orgId: string, profileId: s
}
}
export async function createCourseImportDraftFromCourseService(
orgId: string,
profileId: string,
data: TCourseImportDraftCreateFromCourse
) {
const snapshot = await buildCourseStructureSnapshot(orgId, data.courseId);
return createCourseImportDraftService(orgId, profileId, {
sourceType: 'course',
idempotencyKey: data.idempotencyKey,
summary: {
sourceCourseId: data.courseId,
...(data.summary ?? {})
},
sourceArtifacts: [
{
type: 'course',
courseId: data.courseId,
label: snapshot.draft.course.title
},
...(data.sourceArtifacts ?? [])
],
draft: snapshot.draft
});
}
export async function getCourseImportStructureService(orgId: string, courseId: string) {
try {
return await buildCourseStructureSnapshot(orgId, courseId);
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to fetch course structure',
ErrorCodes.COURSE_IMPORT_FETCH_FAILED,
500
);
}
}
export async function getCourseImportDraftService(orgId: string, draftId: string) {
try {
const draft = await getCourseImportDraftById(orgId, draftId);
@@ -138,18 +523,52 @@ export async function updateCourseImportDraftService(orgId: string, draftId: str
}
}
export async function updateCourseImportDraftTagsService(
orgId: string,
draftId: string,
tagNames: string[],
mode: 'merge' | 'replace'
) {
const existing = await getCourseImportDraftService(orgId, draftId);
if (existing.status === 'PUBLISHED') {
throw new AppError('Published drafts cannot be edited', ErrorCodes.FORBIDDEN, 403);
}
const draft = parseStoredDraft(existing);
const nextTags =
mode === 'replace' ? normalizeDraftTagNames(tagNames) : normalizeDraftTagNames([...draft.tags, ...tagNames]);
return updateCourseImportDraftService(orgId, draftId, {
draft: {
...draft,
tags: nextTags
}
});
}
export async function publishCourseImportDraftService(
orgId: string,
profileId: string,
draftId: string,
overrides: TCourseImportDraftPublish
) {
): Promise<PublishDraftResult> {
try {
const existing = await getCourseImportDraftService(orgId, draftId);
if (existing.status === 'PUBLISHED' && existing.publishedCourseId) {
const [publishedCourse] = await getCourseById(existing.publishedCourseId);
const courseUrl = await resolveCourseUrl(
orgId,
existing.publishedCourseId,
publishedCourse?.title ?? existing.title
);
const draft = parseStoredDraft(existing);
return {
courseId: existing.publishedCourseId,
courseUrl,
bannerImageUrl: publishedCourse?.logo ?? null,
tagNames: normalizeDraftTagNames(draft.tags),
createdSections: 0,
createdLessons: 0,
localeCount: 0
@@ -182,6 +601,9 @@ export async function publishCourseImportDraftService(
await updateCourse(courseId, { metadata: courseMetadata });
}
const bannerImageUrl = await resolvePublishBannerImage(courseTitle, overrides);
await maybeApplyCourseBannerImage(courseId, bannerImageUrl);
const sectionIdMap = new Map<string, string>();
for (const section of [...draft.sections].sort((a, b) => a.order - b.order)) {
const createdSection = await createCourseSection(courseId, {
@@ -203,6 +625,7 @@ export async function publishCourseImportDraftService(
const createdLesson = await createLesson(courseId, {
courseId,
title: lesson.title,
note: getPrimaryLessonContent(lesson.externalId, draft.lessonLanguages, draft.course.locale as TLocale),
sectionId,
order: lesson.order,
isUnlocked: lesson.isUnlocked,
@@ -224,6 +647,16 @@ export async function publishCourseImportDraftService(
});
}
const appliedTagNames = normalizeDraftTagNames(draft.tags);
if (appliedTagNames.length > 0) {
await assignCourseTagsByName(orgId, courseId, {
tagNames: appliedTagNames,
mode: 'replace'
});
}
const courseUrl = await resolveCourseUrl(orgId, courseId, courseTitle);
await updateCourseImportDraft(orgId, draftId, {
status: 'PUBLISHED',
publishedCourseId: courseId,
@@ -236,6 +669,9 @@ export async function publishCourseImportDraftService(
return {
courseId,
courseUrl,
bannerImageUrl,
tagNames: appliedTagNames,
createdSections: sectionIdMap.size,
createdLessons: lessonIdMap.size,
localeCount: new Set(draft.lessonLanguages.map((item) => item.locale)).size
@@ -252,3 +688,184 @@ export async function publishCourseImportDraftService(
);
}
}
export async function publishCourseImportDraftToExistingCourseService(
orgId: string,
draftId: string,
overrides: TCourseImportDraftPublishToCourse
): Promise<PublishDraftToExistingCourseResult> {
try {
const existingDraft = await getCourseImportDraftService(orgId, draftId);
const draft = parseStoredDraft(existingDraft);
const course = await getCourseRecord(orgId, overrides.courseId);
const courseTitle = overrides.title ?? draft.course.title;
const courseDescription = overrides.description ?? draft.course.description;
const courseType = overrides.type ?? draft.course.type;
const courseMetadata = overrides.metadata ?? draft.course.metadata;
const preferredLocale = draft.course.locale as TLocale;
const bannerImageUrl = await resolvePublishBannerImage(courseTitle, overrides);
await updateCourse(course.id, {
title: courseTitle,
description: courseDescription,
type: courseType,
metadata: courseMetadata
});
await maybeApplyCourseBannerImage(course.id, bannerImageUrl);
const [existingSections, existingLessons] = await Promise.all([
listCourseSections(course.id),
listLessons(course.id)
]);
const existingSectionIds = new Set(existingSections.map((section) => section.id));
const existingLessonIds = new Set(existingLessons.map((lesson) => lesson.id));
const existingLessonLanguages = await getLessonLanguagesByLessonIds(existingLessons.map((lesson) => lesson.id));
const sectionIdMap = new Map<string, string>();
let createdSections = 0;
let updatedSections = 0;
for (const section of [...draft.sections].sort((a, b) => a.order - b.order)) {
if (existingSectionIds.has(section.externalId)) {
const updatedSection = await updateCourseSectionService(section.externalId, {
title: section.title,
order: section.order
});
sectionIdMap.set(section.externalId, updatedSection.id);
updatedSections += 1;
continue;
}
const createdSection = await createCourseSection(course.id, {
courseId: course.id,
title: section.title,
order: section.order
});
sectionIdMap.set(section.externalId, createdSection.id);
createdSections += 1;
}
const lessonIdMap = new Map<string, string>();
let createdLessons = 0;
let updatedLessons = 0;
for (const lesson of [...draft.lessons].sort((a, b) => a.order - b.order)) {
const sectionId = sectionIdMap.get(lesson.sectionExternalId);
if (!sectionId) {
throw new AppError(`Lesson "${lesson.title}" references an unknown section`, ErrorCodes.VALIDATION_ERROR, 400);
}
const note = getPrimaryLessonContent(lesson.externalId, draft.lessonLanguages, preferredLocale);
if (existingLessonIds.has(lesson.externalId)) {
const updatedLesson = await updateLessonService(lesson.externalId, {
title: lesson.title,
note,
sectionId,
order: lesson.order,
isUnlocked: lesson.isUnlocked,
public: lesson.public
});
lessonIdMap.set(lesson.externalId, updatedLesson.id);
updatedLessons += 1;
continue;
}
const createdLesson = await createLesson(course.id, {
courseId: course.id,
title: lesson.title,
note,
sectionId,
order: lesson.order,
isUnlocked: lesson.isUnlocked,
public: lesson.public
});
lessonIdMap.set(lesson.externalId, createdLesson.id);
createdLessons += 1;
}
const existingLessonLanguageMap = new Map(
existingLessonLanguages.map((lessonLanguage) => [
`${lessonLanguage.lessonId}:${lessonLanguage.locale}`,
lessonLanguage
])
);
let createdLessonLanguages = 0;
let updatedLessonLanguages = 0;
for (const lessonLanguage of draft.lessonLanguages) {
const lessonId = lessonIdMap.get(lessonLanguage.lessonExternalId);
if (!lessonId) {
throw new AppError('Referenced lesson was not created', ErrorCodes.VALIDATION_ERROR, 400);
}
const languageKey = `${lessonId}:${lessonLanguage.locale}`;
if (existingLessonLanguageMap.has(languageKey)) {
await updateLessonLanguageService(lessonId, lessonLanguage.locale as TLocale, {
content: lessonLanguage.content
});
updatedLessonLanguages += 1;
continue;
}
await upsertLessonLanguageService(lessonId, {
locale: lessonLanguage.locale as TLocale,
content: lessonLanguage.content
});
createdLessonLanguages += 1;
}
const appliedTagNames = normalizeDraftTagNames(draft.tags);
if (appliedTagNames.length > 0) {
await assignCourseTagsByName(orgId, course.id, {
tagNames: appliedTagNames,
mode: 'merge'
});
}
const referencedSectionIds = new Set([...sectionIdMap.values()].filter((id) => existingSectionIds.has(id)));
const referencedLessonIds = new Set([...lessonIdMap.values()].filter((id) => existingLessonIds.has(id)));
const courseUrl = await resolveCourseUrl(orgId, course.id, courseTitle);
await updateCourseImportDraft(orgId, draftId, {
status: 'PUBLISHED',
publishedCourseId: course.id,
summary: {
...(existingDraft.summary ?? {}),
...buildDraftSummary(draft),
publishedCourseId: course.id,
updatedSections,
updatedLessons,
createdLessonLanguages,
updatedLessonLanguages
}
});
return {
courseId: course.id,
courseUrl,
bannerImageUrl,
tagNames: appliedTagNames,
createdSections,
updatedSections,
createdLessons,
updatedLessons,
createdLessonLanguages,
updatedLessonLanguages,
untouchedSections: existingSections.length - referencedSectionIds.size,
untouchedLessons: existingLessons.length - referencedLessonIds.size,
localeCount: new Set(draft.lessonLanguages.map((item) => item.locale)).size
};
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to publish course import draft to existing course',
ErrorCodes.COURSE_IMPORT_PUBLISH_FAILED,
500
);
}
}
+218 -115
View File
@@ -1,13 +1,21 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { ContentType } from '@cio/utils/constants';
import { db } from '@cio/db/drizzle';
import { db, type DbOrTxClient } from '@cio/db/drizzle';
import { sql, inArray } from 'drizzle-orm';
import { lesson, exercise } from '@cio/db/schema';
import { courseSection, lesson, exercise } from '@cio/db/schema';
import { getCourseContentItems, type CourseContentItemRow } from '@cio/db/queries/course/content';
import { deleteLesson } from '@cio/db/queries/lesson';
import { deleteCourseSection, getCourseSectionById } from '@cio/db/queries/course';
import { deleteCourseSection, getCourseSectionById, getCourseSectionsByCourseId } from '@cio/db/queries/course';
import { deleteExercise } from '@cio/db/queries/exercise/exercise';
import type { TCourseContentDelete, TCourseContentUpdate } from '@cio/utils/validation/course';
import type { TCourseContentDelete, TCourseContentReorder, TCourseContentUpdate } from '@cio/utils/validation/course';
type CourseContentReorderResult = {
courseId: string;
updatedSections: number;
updatedItems: number;
updatedLessons: number;
updatedExercises: number;
};
function buildUpdatePayload(item: TCourseContentUpdate['items'][number]) {
const payload: Record<string, unknown> = {};
@@ -64,122 +72,175 @@ async function assertCourseContentItems(courseId: string, items: TCourseContentU
});
}
async function assertCourseSections(courseId: string, sections: NonNullable<TCourseContentReorder['sections']>) {
const existingSections = await getCourseSectionsByCourseId(courseId);
const sectionIds = new Set(existingSections.map((section) => section.id));
sections.forEach((section) => {
if (!sectionIds.has(section.id)) {
throw new AppError('Course section not found', ErrorCodes.COURSE_SECTION_NOT_FOUND, 404);
}
});
}
async function applyCourseContentUpdates(tx: DbOrTxClient, items: TCourseContentUpdate['items']) {
const updates = items
.map((item) => ({
item,
payload: buildUpdatePayload(item)
}))
.filter(({ payload }) => Object.keys(payload).length > 0);
if (updates.length === 0) {
return {
updatedItems: 0,
updatedLessons: 0,
updatedExercises: 0
};
}
const updatedAt = new Date().toISOString();
const lessonUpdates = updates.filter(({ item }) => item.type === ContentType.Lesson);
const exerciseUpdates = updates.filter(({ item }) => item.type === ContentType.Exercise);
if (lessonUpdates.length > 0) {
const lessonIds = lessonUpdates.map(({ item }) => item.id);
const lessonOrderUpdates = lessonUpdates
.filter(({ payload }) => payload.order !== undefined)
.map(({ item, payload }) => ({ id: item.id, value: payload.order }));
const lessonSectionUpdates = lessonUpdates
.filter(({ payload }) => payload.sectionId !== undefined)
.map(({ item, payload }) => ({ id: item.id, value: payload.sectionId }));
const lessonUnlockUpdates = lessonUpdates
.filter(({ payload }) => payload.isUnlocked !== undefined)
.map(({ item, payload }) => ({ id: item.id, value: payload.isUnlocked }));
const lessonSet: Record<string, unknown> = {
updatedAt
};
if (lessonOrderUpdates.length > 0) {
lessonSet.order = sql`case ${lesson.id} ${sql.join(
lessonOrderUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
sql` `
)} else ${lesson.order} end`;
}
if (lessonSectionUpdates.length > 0) {
lessonSet.sectionId = sql`case ${lesson.id} ${sql.join(
lessonSectionUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
sql` `
)} else ${lesson.sectionId} end`;
}
if (lessonUnlockUpdates.length > 0) {
lessonSet.isUnlocked = sql`case ${lesson.id} ${sql.join(
lessonUnlockUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
sql` `
)} else ${lesson.isUnlocked} end`;
}
const updatedLessons = await tx
.update(lesson)
.set(lessonSet)
.where(inArray(lesson.id, lessonIds))
.returning({ id: lesson.id });
if (updatedLessons.length !== lessonIds.length) {
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
}
}
if (exerciseUpdates.length > 0) {
const exerciseIds = exerciseUpdates.map(({ item }) => item.id);
const exerciseOrderUpdates = exerciseUpdates
.filter(({ payload }) => payload.order !== undefined)
.map(({ item, payload }) => ({ id: item.id, value: payload.order }));
const exerciseSectionUpdates = exerciseUpdates
.filter(({ payload }) => payload.sectionId !== undefined)
.map(({ item, payload }) => ({ id: item.id, value: payload.sectionId }));
const exerciseUnlockUpdates = exerciseUpdates
.filter(({ payload }) => payload.isUnlocked !== undefined)
.map(({ item, payload }) => ({ id: item.id, value: payload.isUnlocked }));
const exerciseSet: Record<string, unknown> = {
updatedAt
};
if (exerciseOrderUpdates.length > 0) {
exerciseSet.order = sql`case ${exercise.id} ${sql.join(
exerciseOrderUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
sql` `
)} else ${exercise.order} end`;
}
if (exerciseSectionUpdates.length > 0) {
exerciseSet.sectionId = sql`case ${exercise.id} ${sql.join(
exerciseSectionUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
sql` `
)} else ${exercise.sectionId} end`;
}
if (exerciseUnlockUpdates.length > 0) {
exerciseSet.isUnlocked = sql`case ${exercise.id} ${sql.join(
exerciseUnlockUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
sql` `
)} else ${exercise.isUnlocked} end`;
}
const updatedExercises = await tx
.update(exercise)
.set(exerciseSet)
.where(inArray(exercise.id, exerciseIds))
.returning({ id: exercise.id });
if (updatedExercises.length !== exerciseIds.length) {
throw new AppError('Exercise not found', ErrorCodes.EXERCISE_NOT_FOUND, 404);
}
}
return {
updatedItems: updates.length,
updatedLessons: lessonUpdates.length,
updatedExercises: exerciseUpdates.length
};
}
async function applySectionOrderUpdates(tx: DbOrTxClient, sections: NonNullable<TCourseContentReorder['sections']>) {
if (sections.length === 0) {
return 0;
}
const sectionIds = sections.map((section) => section.id);
const updatedAt = new Date().toISOString();
const sectionSet: Record<string, unknown> = {
updatedAt,
order: sql`case ${courseSection.id} ${sql.join(
sections.map((section) => sql`when ${section.id} then ${section.order}`),
sql` `
)} else ${courseSection.order} end`
};
const updatedSections = await tx
.update(courseSection)
.set(sectionSet)
.where(inArray(courseSection.id, sectionIds))
.returning({ id: courseSection.id });
if (updatedSections.length !== sectionIds.length) {
throw new AppError('Course section not found', ErrorCodes.COURSE_SECTION_NOT_FOUND, 404);
}
return sections.length;
}
export async function updateCourseContent(courseId: string, items: TCourseContentUpdate['items']) {
try {
await assertCourseContentItems(courseId, items);
await db.transaction(async (tx) => {
const updates = items
.map((item) => ({
item,
payload: buildUpdatePayload(item)
}))
.filter(({ payload }) => Object.keys(payload).length > 0);
if (updates.length === 0) {
return;
}
const updatedAt = new Date().toISOString();
const lessonUpdates = updates.filter(({ item }) => item.type === ContentType.Lesson);
const exerciseUpdates = updates.filter(({ item }) => item.type === ContentType.Exercise);
if (lessonUpdates.length > 0) {
const lessonIds = lessonUpdates.map(({ item }) => item.id);
const lessonOrderUpdates = lessonUpdates
.filter(({ payload }) => payload.order !== undefined)
.map(({ item, payload }) => ({ id: item.id, value: payload.order }));
const lessonSectionUpdates = lessonUpdates
.filter(({ payload }) => payload.sectionId !== undefined)
.map(({ item, payload }) => ({ id: item.id, value: payload.sectionId }));
const lessonUnlockUpdates = lessonUpdates
.filter(({ payload }) => payload.isUnlocked !== undefined)
.map(({ item, payload }) => ({ id: item.id, value: payload.isUnlocked }));
const lessonSet: Record<string, unknown> = {
updatedAt
};
if (lessonOrderUpdates.length > 0) {
lessonSet.order = sql`case ${lesson.id} ${sql.join(
lessonOrderUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
sql` `
)} else ${lesson.order} end`;
}
if (lessonSectionUpdates.length > 0) {
lessonSet.sectionId = sql`case ${lesson.id} ${sql.join(
lessonSectionUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
sql` `
)} else ${lesson.sectionId} end`;
}
if (lessonUnlockUpdates.length > 0) {
lessonSet.isUnlocked = sql`case ${lesson.id} ${sql.join(
lessonUnlockUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
sql` `
)} else ${lesson.isUnlocked} end`;
}
const updatedLessons = await tx
.update(lesson)
.set(lessonSet)
.where(inArray(lesson.id, lessonIds))
.returning({ id: lesson.id });
if (updatedLessons.length !== lessonIds.length) {
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
}
}
if (exerciseUpdates.length > 0) {
const exerciseIds = exerciseUpdates.map(({ item }) => item.id);
const exerciseOrderUpdates = exerciseUpdates
.filter(({ payload }) => payload.order !== undefined)
.map(({ item, payload }) => ({ id: item.id, value: payload.order }));
const exerciseSectionUpdates = exerciseUpdates
.filter(({ payload }) => payload.sectionId !== undefined)
.map(({ item, payload }) => ({ id: item.id, value: payload.sectionId }));
const exerciseUnlockUpdates = exerciseUpdates
.filter(({ payload }) => payload.isUnlocked !== undefined)
.map(({ item, payload }) => ({ id: item.id, value: payload.isUnlocked }));
const exerciseSet: Record<string, unknown> = {
updatedAt
};
if (exerciseOrderUpdates.length > 0) {
exerciseSet.order = sql`case ${exercise.id} ${sql.join(
exerciseOrderUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
sql` `
)} else ${exercise.order} end`;
}
if (exerciseSectionUpdates.length > 0) {
exerciseSet.sectionId = sql`case ${exercise.id} ${sql.join(
exerciseSectionUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
sql` `
)} else ${exercise.sectionId} end`;
}
if (exerciseUnlockUpdates.length > 0) {
exerciseSet.isUnlocked = sql`case ${exercise.id} ${sql.join(
exerciseUnlockUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
sql` `
)} else ${exercise.isUnlocked} end`;
}
const updatedExercises = await tx
.update(exercise)
.set(exerciseSet)
.where(inArray(exercise.id, exerciseIds))
.returning({ id: exercise.id });
if (updatedExercises.length !== exerciseIds.length) {
throw new AppError('Exercise not found', ErrorCodes.EXERCISE_NOT_FOUND, 404);
}
}
await applyCourseContentUpdates(tx, items);
});
} catch (error) {
if (error instanceof AppError) {
@@ -194,6 +255,48 @@ export async function updateCourseContent(courseId: string, items: TCourseConten
}
}
export async function reorderCourseContent(
courseId: string,
payload: TCourseContentReorder
): Promise<CourseContentReorderResult> {
try {
if (payload.sections?.length) {
await assertCourseSections(courseId, payload.sections);
}
if (payload.items?.length) {
await assertCourseContentItems(courseId, payload.items);
}
return await db.transaction(async (tx) => {
const updatedSections = payload.sections?.length ? await applySectionOrderUpdates(tx, payload.sections) : 0;
const itemResult = payload.items?.length
? await applyCourseContentUpdates(tx, payload.items)
: {
updatedItems: 0,
updatedLessons: 0,
updatedExercises: 0
};
return {
courseId,
updatedSections,
...itemResult
};
});
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to reorder course content',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function deleteCourseContent(courseId: string, payload: TCourseContentDelete) {
try {
if (payload.sectionId) {
@@ -0,0 +1,224 @@
import * as schema from '@cio/db/schema';
import { AppError, ErrorCodes } from '@api/utils/errors';
import type { TCourse, TOrganization } from '@db/types';
import type { TCourseLandingPageMetadataUpdate, TCourseLandingPageUpdate } from '@cio/utils/validation/course';
import { db } from '@cio/db/drizzle';
import { env } from '@api/config/env';
import { eq } from 'drizzle-orm';
import { generateSlug } from '@cio/utils/functions';
import { getCourseById } from '@cio/db/queries/course';
import { getCourseOrganizationId } from '@cio/db/queries/tag';
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { getOrganizationById } from '@cio/db/queries/organization';
import { updateCourse } from '@api/services/course/course';
type CourseLandingPageUpdateResult = {
course: TCourse;
courseUrl: string;
bannerImageUrl: string | null;
};
async function generateUniqueCourseSlug(baseSlug: string): Promise<string> {
let suffix = 0;
let candidate = baseSlug;
while (true) {
const existing = await db
.select({ id: schema.course.id })
.from(schema.course)
.where(eq(schema.course.slug, candidate))
.limit(1);
if (existing.length === 0) {
return candidate;
}
suffix += 1;
candidate = `${baseSlug}-${suffix}`;
}
}
async function ensureCourseSlug(courseId: string, title: string | null | undefined): Promise<string> {
const [course] = await getCourseById(courseId);
if (course?.slug) {
return course.slug;
}
const baseSlug = generateSlug(title, { fallback: 'course' });
const uniqueSlug = await generateUniqueCourseSlug(baseSlug);
await updateCourse(courseId, { slug: uniqueSlug });
return uniqueSlug;
}
function buildCourseBaseUrl(organization: TOrganization) {
if (organization.customDomain && organization.isCustomDomainVerified) {
return `https://${organization.customDomain}`;
}
return getDashboardBaseUrl(organization.siteName ?? undefined);
}
async function resolveCourseUrl(organizationId: string, courseId: string, title: string) {
const organization = await getOrganizationById(organizationId);
if (!organization) {
throw new AppError('Organization not found', ErrorCodes.ORG_NOT_FOUND, 404);
}
const slug = await ensureCourseSlug(courseId, title);
return `${buildCourseBaseUrl(organization)}/course/${encodeURIComponent(slug)}`;
}
async function resolveUnsplashBannerImage(courseTitle: string, query?: string) {
if (!env.UNSPLASH_API_KEY) {
return null;
}
const searchQuery = (query?.trim() || courseTitle.trim() || 'education').slice(0, 120);
const response = await fetch(
`https://api.unsplash.com/search/photos?page=1&per_page=15&auto=format&fit=crop&w=2970&q=80&client_id=${env.UNSPLASH_API_KEY}&query=${encodeURIComponent(searchQuery)}`,
{ method: 'GET' }
);
if (!response.ok) {
return null;
}
const data = (await response.json()) as {
results?: Array<{
urls?: {
regular?: string;
};
}>;
};
const photos = data.results ?? [];
if (photos.length === 0) {
return null;
}
const photo = photos[Math.floor(Math.random() * photos.length)];
return photo?.urls?.regular ?? null;
}
async function resolveLandingPageImage(
courseTitle: string,
payload: Pick<TCourseLandingPageUpdate, 'generateImage' | 'imageQuery' | 'imageUrl'>
) {
if (payload.imageUrl) {
return payload.imageUrl;
}
if (!payload.generateImage && !payload.imageQuery) {
return null;
}
try {
return await resolveUnsplashBannerImage(courseTitle, payload.imageQuery);
} catch (error) {
console.error('resolveLandingPageImage error:', error);
return null;
}
}
function mergeLandingPageMetadata(
existing: TCourse['metadata'] | null | undefined,
update: TCourseLandingPageMetadataUpdate | undefined
): TCourse['metadata'] | undefined {
if (!update) {
return existing ?? undefined;
}
const current: NonNullable<TCourse['metadata']> = {
goals: '',
description: '',
requirements: '',
allowNewStudent: true,
...(existing ?? {})
};
return {
...current,
...update,
reward: update.reward
? {
show: update.reward.show ?? current.reward?.show ?? false,
description: update.reward.description ?? current.reward?.description ?? ''
}
: current.reward,
instructor: update.instructor
? {
name: update.instructor.name ?? current.instructor?.name ?? '',
role: update.instructor.role ?? current.instructor?.role ?? '',
coursesNo: update.instructor.coursesNo ?? current.instructor?.coursesNo ?? 0,
description: update.instructor.description ?? current.instructor?.description ?? '',
imgUrl: update.instructor.imgUrl ?? current.instructor?.imgUrl ?? ''
}
: current.instructor,
certificate: update.certificate
? {
templateUrl: update.certificate.templateUrl ?? current.certificate?.templateUrl ?? ''
}
: current.certificate,
sectionDisplay: update.sectionDisplay
? { ...(current.sectionDisplay ?? {}), ...update.sectionDisplay }
: current.sectionDisplay,
reviews: update.reviews ?? current.reviews,
lessonTabsOrder: update.lessonTabsOrder ?? current.lessonTabsOrder
};
}
function normalizeReviews(reviews: NonNullable<TCourse['metadata']>['reviews'] | undefined) {
return reviews?.map((review, index) => ({
id: review.id ?? Date.now() + index,
hide: review.hide ?? false,
name: review.name,
avatar_url: review.avatar_url,
rating: review.rating,
created_at: review.created_at ?? Date.now(),
description: review.description
}));
}
export async function updateCourseLandingPageService(
courseId: string,
payload: TCourseLandingPageUpdate
): Promise<CourseLandingPageUpdateResult> {
const [existingCourse] = await getCourseById(courseId);
if (!existingCourse) {
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
}
const nextTitle = payload.title ?? existingCourse.title;
const metadata = mergeLandingPageMetadata(existingCourse.metadata ?? undefined, payload.metadata);
const imageUrl = await resolveLandingPageImage(nextTitle, payload);
const updatedCourse = await updateCourse(courseId, {
title: payload.title,
description: payload.description,
overview: payload.overview,
cost: payload.cost,
currency: payload.currency,
metadata: metadata
? {
...metadata,
reviews: normalizeReviews(metadata.reviews)
}
: undefined,
...(imageUrl ? { logo: imageUrl, bannerImage: imageUrl } : {})
});
const organizationId = await getCourseOrganizationId(courseId);
if (!organizationId) {
throw new AppError('Course organization not found', ErrorCodes.COURSE_NOT_FOUND, 404);
}
const courseUrl = await resolveCourseUrl(organizationId, courseId, updatedCourse.title);
return {
course: updatedCourse,
courseUrl,
bannerImageUrl: updatedCourse.logo || imageUrl || null
};
}
@@ -15,13 +15,19 @@ import {
listOrganizationApiKeys,
updateOrganizationApiKey
} from '@cio/db/queries/organization';
import { assertOrganizationAutomationKeyCreationAllowed } from './automation-usage';
const DEFAULT_SCOPES: Record<TOrganizationApiKeyType, TOrganizationApiKeyScope[]> = {
mcp: [
'course_import:draft:create',
'course_import:draft:read',
'course_import:draft:update',
'course_import:draft:publish'
'course_import:draft:publish',
'course:read',
'course:write',
'course:tag:write',
'course:exercise:read',
'course:exercise:write'
],
api: ['public_api:*'],
zapier: ['public_api:*']
@@ -86,6 +92,8 @@ export async function createOrganizationApiKeyService(
createdByProfileId: string,
payload: TCreateOrganizationApiKey
): Promise<{ key: OrganizationApiKeySummary; secret: string }> {
await assertOrganizationAutomationKeyCreationAllowed(organizationId, payload.type);
const secret = generateOrganizationApiKeySecret(payload.type);
const key = await createOrganizationApiKey({
organizationId,
@@ -0,0 +1,188 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import {
countActiveOrganizationApiKeys,
countOrganizationAutomationUsageSince,
countOrganizationAutomationUsageSinceByKey,
createOrganizationAutomationUsage,
getActiveOrganizationPlan,
getOrganizationAutomationCreditsUsedSince,
listRecentOrganizationAutomationUsage
} from '@cio/db/queries/organization';
import type { TOrganizationApiKey, TOrganizationApiKeyType, TPlan } from '@db/types';
import {
AUTOMATION_TYPE,
getMcpAutomationCategory,
getMcpAutomationLimits,
MCP_TOOL_CREDIT_COST,
type TMcpToolName
} from '@cio/utils/plans';
export type OrganizationAutomationUsageSummary = {
type: TOrganizationApiKeyType;
planName: TPlan;
billingPeriodStart: string;
billingPeriodEnd: string;
monthlyCreditsIncluded: number;
monthlyCreditsUsed: number;
monthlyCreditsRemaining: number;
activeKeys: number;
maxActiveKeys: number;
rateLimits: ReturnType<typeof getMcpAutomationLimits>['rateLimits'];
recentActions: Array<{
action: string;
creditsConsumed: number;
createdAt: string;
}>;
};
function getBillingPeriodStart(date: Date = new Date()) {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1, 0, 0, 0, 0));
}
function getBillingPeriodEnd(date: Date = new Date()) {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1, 0, 0, 0, 0));
}
function getMinuteWindowStart(date: Date = new Date()) {
return new Date(date.getTime() - 60_000);
}
async function getOrganizationPlanName(orgId: string): Promise<TPlan> {
const activePlan = await getActiveOrganizationPlan(orgId);
return (activePlan?.planName as TPlan | null) ?? 'BASIC';
}
export async function assertOrganizationAutomationKeyCreationAllowed(
organizationId: string,
type: TOrganizationApiKeyType
): Promise<void> {
if (type !== AUTOMATION_TYPE.MCP) {
return;
}
const planName = await getOrganizationPlanName(organizationId);
const limits = getMcpAutomationLimits(planName);
const activeKeys = await countActiveOrganizationApiKeys(organizationId, type);
if (activeKeys >= limits.maxActiveKeys) {
throw new AppError(
`Your plan allows up to ${limits.maxActiveKeys} active MCP key${limits.maxActiveKeys === 1 ? '' : 's'}`,
ErrorCodes.AUTOMATION_KEY_LIMIT_EXCEEDED,
403
);
}
}
export async function getOrganizationAutomationUsageSummaryService(
organizationId: string,
type: TOrganizationApiKeyType
): Promise<OrganizationAutomationUsageSummary> {
const planName = await getOrganizationPlanName(organizationId);
const billingPeriodStart = getBillingPeriodStart();
const billingPeriodEnd = getBillingPeriodEnd();
if (type !== AUTOMATION_TYPE.MCP) {
return {
type,
planName,
billingPeriodStart: billingPeriodStart.toISOString(),
billingPeriodEnd: billingPeriodEnd.toISOString(),
monthlyCreditsIncluded: 0,
monthlyCreditsUsed: 0,
monthlyCreditsRemaining: 0,
activeKeys: await countActiveOrganizationApiKeys(organizationId, type),
maxActiveKeys: 0,
rateLimits: {
perKey: { readPerMinute: 0, writePerMinute: 0, publishPerMinute: 0 },
perOrg: { readPerMinute: 0, writePerMinute: 0, publishPerMinute: 0 }
},
recentActions: []
};
}
const limits = getMcpAutomationLimits(planName);
const [monthlyCreditsUsed, activeKeys, recentActions] = await Promise.all([
getOrganizationAutomationCreditsUsedSince(organizationId, type, billingPeriodStart.toISOString()),
countActiveOrganizationApiKeys(organizationId, type),
listRecentOrganizationAutomationUsage(organizationId, type, 10)
]);
return {
type,
planName,
billingPeriodStart: billingPeriodStart.toISOString(),
billingPeriodEnd: billingPeriodEnd.toISOString(),
monthlyCreditsIncluded: limits.monthlyCredits,
monthlyCreditsUsed,
monthlyCreditsRemaining: Math.max(limits.monthlyCredits - monthlyCreditsUsed, 0),
activeKeys,
maxActiveKeys: limits.maxActiveKeys,
rateLimits: limits.rateLimits,
recentActions: recentActions.map((action) => ({
action: action.action,
creditsConsumed: action.creditsConsumed,
createdAt: action.createdAt
}))
};
}
export async function assertMcpAutomationUsageAllowed(
automationKey: TOrganizationApiKey,
toolName: TMcpToolName
): Promise<void> {
const planName = await getOrganizationPlanName(automationKey.organizationId);
const limits = getMcpAutomationLimits(planName);
const category = getMcpAutomationCategory(toolName);
const now = new Date();
const billingPeriodStart = getBillingPeriodStart(now).toISOString();
const minuteWindowStart = getMinuteWindowStart(now).toISOString();
const [creditsUsed, keyRequestsInWindow, orgRequestsInWindow] = await Promise.all([
getOrganizationAutomationCreditsUsedSince(automationKey.organizationId, automationKey.type, billingPeriodStart),
countOrganizationAutomationUsageSinceByKey(automationKey.id, category, minuteWindowStart),
countOrganizationAutomationUsageSince(automationKey.organizationId, automationKey.type, category, minuteWindowStart)
]);
const creditsNeeded = MCP_TOOL_CREDIT_COST[toolName];
if (creditsUsed + creditsNeeded > limits.monthlyCredits) {
throw new AppError(
'Monthly automation credits exhausted for this organization',
ErrorCodes.AUTOMATION_CREDIT_LIMIT_EXCEEDED,
429
);
}
const keyLimit =
category === 'read'
? limits.rateLimits.perKey.readPerMinute
: category === 'write'
? limits.rateLimits.perKey.writePerMinute
: limits.rateLimits.perKey.publishPerMinute;
const orgLimit =
category === 'read'
? limits.rateLimits.perOrg.readPerMinute
: category === 'write'
? limits.rateLimits.perOrg.writePerMinute
: limits.rateLimits.perOrg.publishPerMinute;
if (keyRequestsInWindow >= keyLimit || orgRequestsInWindow >= orgLimit) {
throw new AppError('Automation rate limit exceeded', ErrorCodes.AUTOMATION_RATE_LIMIT_EXCEEDED, 429);
}
}
export async function recordMcpAutomationUsage(
automationKey: TOrganizationApiKey,
toolName: TMcpToolName,
metadata: Record<string, unknown> = {}
): Promise<void> {
await createOrganizationAutomationUsage({
organizationId: automationKey.organizationId,
organizationApiKeyId: automationKey.id,
type: automationKey.type,
action: toolName,
category: getMcpAutomationCategory(toolName),
creditsConsumed: MCP_TOOL_CREDIT_COST[toolName],
metadata
});
}
+71
View File
@@ -1,4 +1,6 @@
import type {
TAutomationCourseTagAssignment,
TAutomationDraftTagAssignment,
TCourseTagAssignment,
TTagCreate,
TTagGroupCreate,
@@ -8,6 +10,8 @@ import type {
import {
createTag,
createTagGroup,
findTagBySlug,
findTagGroupBySlug,
generateUniqueTagGroupSlug,
generateUniqueTagSlug,
getCourseOrganizationId,
@@ -21,6 +25,8 @@ import {
updateTagGroup
} from '@cio/db/queries/tag';
import { getOrgIdBySiteName, isUserOrgAdmin } from '@cio/db/queries/organization';
import { TAG_COLOR_OPTIONS, type TTagColor } from '@cio/utils/validation/tag';
import { slugifyTagValue } from '@cio/db/queries/tag';
import { AppError, ErrorCodes } from '@api/utils/errors';
@@ -34,6 +40,51 @@ function normalizeOptionalText(value: string | undefined): string | null | undef
return normalized.length > 0 ? normalized : null;
}
function normalizeAutomationTagNames(tagNames: string[]) {
return Array.from(new Map(tagNames.map((name) => [name.trim().toLowerCase(), name.trim()])).values()).filter(Boolean);
}
function pickTagColor(name: string): TTagColor {
const seed = Array.from(name).reduce((acc, char) => acc + char.charCodeAt(0), 0);
return TAG_COLOR_OPTIONS[seed % TAG_COLOR_OPTIONS.length] as TTagColor;
}
async function ensureAutomationTagGroup(orgId: string, groupName: string) {
const slug = slugifyTagValue(groupName);
const existing = await findTagGroupBySlug(orgId, slug);
if (existing) {
return existing;
}
return createOrganizationTagGroup(orgId, { name: groupName });
}
async function ensureAutomationTags(orgId: string, tagNames: string[], groupName: string) {
const normalizedTagNames = normalizeAutomationTagNames(tagNames);
const group = await ensureAutomationTagGroup(orgId, groupName);
const tags = [];
for (const tagName of normalizedTagNames) {
const slug = slugifyTagValue(tagName);
const existing = await findTagBySlug(orgId, slug);
if (existing) {
tags.push(existing);
continue;
}
const created = await createOrganizationTag(orgId, {
groupId: group.id,
name: tagName,
color: pickTagColor(tagName)
});
tags.push(created);
}
return tags;
}
export async function getOrganizationTagGroups(orgId: string) {
try {
return getTagGroupsWithTags(orgId);
@@ -301,3 +352,23 @@ export async function replaceCourseTags(
);
}
}
export async function assignCourseTagsByName(orgId: string, courseId: string, data: TAutomationDraftTagAssignment) {
const ensuredTags = await ensureAutomationTags(orgId, data.tagNames, data.groupName?.trim() || 'Automation');
const existingTags = data.mode === 'merge' ? await getCourseTags(orgId, courseId) : [];
const tagIds = Array.from(new Set([...existingTags.map((tag) => tag.id), ...ensuredTags.map((tag) => tag.id)]));
return replaceCourseTags(orgId, courseId, { tagIds });
}
export async function assignTagsToCoursesByName(orgId: string, data: TAutomationCourseTagAssignment) {
const normalizedCourseIds = Array.from(new Set(data.courseIds));
const results = [];
for (const courseId of normalizedCourseIds) {
const tags = await assignCourseTagsByName(orgId, courseId, data);
results.push({ courseId, tags });
}
return results;
}