feat: certificates, landing pages, ai tutor, website update, compliance

This commit is contained in:
Rotimi Best
2026-05-18 03:47:22 +01:00
parent c31ad63955
commit ac3a2c14a7
644 changed files with 56050 additions and 5439 deletions
+55 -215
View File
@@ -4,14 +4,11 @@ import { getCourseTagsByCourseIdsForOrganization, getTagGroupsWithTags } from '@
import { listWidgetCourses } from '@cio/db/queries/widget';
import { PLAN } from '@cio/utils/plans';
import {
ZWidgetPayload,
getLayoutCourseLimit,
buildWidgetPayload as buildWidgetPayloadShared,
normalizeWidgetConfig,
resolveWidgetPlanGatedFields,
type TWidgetLayoutType,
type TWidgetPayload,
type TWidgetPayloadCategory,
type TWidgetPayloadCourseTag
type BuildWidgetPayloadCourse,
type TWidgetPayload
} from '@cio/utils/validation/widget';
import type { TWidget } from '@db/types';
import * as csstree from 'css-tree';
@@ -107,7 +104,7 @@ export function generateWidgetPublicKey(): string {
return `wgt_${encodeBase58(bytes).slice(0, 16)}`;
}
function sanitizeCustomCssValue(customCss: string, planName?: string | null): string {
export function sanitizeCustomCssValue(customCss: string, planName?: string | null): string {
if (!customCss.trim() || !planName || planName === PLAN.BASIC) {
return '';
}
@@ -146,7 +143,7 @@ function sanitizeCustomCssValue(customCss: string, planName?: string | null): st
return csstree.generate(ast);
}
function getCourseBaseUrl(siteName: string, customDomain?: string | null): string {
export function getCourseBaseUrl(siteName: string, customDomain?: string | null): string {
if (customDomain) {
return `https://${customDomain}`;
}
@@ -174,6 +171,28 @@ export function getWidgetEmbedCode(publicKey: string): string {
return `<div data-cio-widget="course-widget" data-widget-key="${publicKey}" data-api-base-url="${apiBaseUrl}"></div>\n<script async type="module" src="${scriptUrl}"></script>`;
}
type OrgCourse = Awaited<ReturnType<typeof getOrgCourses>>['items'][number];
export function formatCourseForWidget(
course: OrgCourse,
tags: BuildWidgetPayloadCourse['tags']
): BuildWidgetPayloadCourse {
return {
id: course.id,
slug: course.slug ?? course.id,
title: course.title,
description: course.description,
imageUrl: course.bannerImage || course.logo || null,
isPublished: course.isPublished,
price: course.cost ? `${course.currency} ${course.cost}` : 'Free',
lessonCount: course.lessonCount,
exerciseCount: course.exerciseCount ?? 0,
courseType: course.type,
createdAt: course.createdAt ?? new Date().toISOString(),
tags
};
}
export async function buildWidgetPayload(
widget: TWidget,
overrideSelectedCourseIds?: string[]
@@ -184,216 +203,47 @@ export async function buildWidgetPayload(
}
const activePlan = await getActiveOrganizationPlan(widget.organizationId);
const normalizedConfig = normalizeWidgetConfig(widget.config as Record<string, unknown>, activePlan?.planName);
const planName = activePlan?.planName ?? null;
const normalizedConfig = normalizeWidgetConfig(widget.config as Record<string, unknown>, planName);
const selectedCourseIds =
overrideSelectedCourseIds ?? (await listWidgetCourses(widget.id)).map((course) => course.courseId);
const allCourses = await getOrgCourses({ orgId: widget.organizationId, limit: 200 });
const allPublishedCourses = allCourses.items.filter((course) => course.isPublished);
const manualOrderLookup = new Map(selectedCourseIds.map((courseId, index) => [courseId, index]));
let resolvedCourses =
widget.selectionMode === 'manual'
? allCourses.items.filter((course) => manualOrderLookup.has(course.id))
: allPublishedCourses;
const allCoursesResult = await getOrgCourses({ orgId: widget.organizationId, limit: 200 });
const tagsByCourseId = await getCourseTagsByCourseIdsForOrganization(
widget.organizationId,
resolvedCourses.map((course) => course.id)
allCoursesResult.items.map((course) => course.id)
);
if (normalizedConfig.filters.includeTagIds.length > 0 || normalizedConfig.filters.excludeTagIds.length > 0) {
resolvedCourses = resolvedCourses.filter((course) => {
const courseTags = tagsByCourseId[course.id] ?? [];
const courseTagIds = new Set(courseTags.map((tag) => tag.id));
const includesRequired =
normalizedConfig.filters.includeTagIds.length === 0 ||
normalizedConfig.filters.includeTagIds.some((tagId) => courseTagIds.has(tagId));
const excludesBlocked = normalizedConfig.filters.excludeTagIds.every((tagId) => !courseTagIds.has(tagId));
const allCourses = allCoursesResult.items.map((course) =>
formatCourseForWidget(course, tagsByCourseId[course.id] ?? [])
);
return includesRequired && excludesBlocked;
});
}
if (widget.selectionMode === 'manual' || normalizedConfig.sortBy === 'manual') {
resolvedCourses = [...resolvedCourses].sort(
(courseA, courseB) =>
(manualOrderLookup.get(courseA.id) ?? Number.MAX_SAFE_INTEGER) -
(manualOrderLookup.get(courseB.id) ?? Number.MAX_SAFE_INTEGER)
);
} else if (normalizedConfig.sortBy === 'title') {
resolvedCourses = [...resolvedCourses].sort((courseA, courseB) => courseA.title.localeCompare(courseB.title));
} else {
resolvedCourses = [...resolvedCourses].sort(
(courseA, courseB) => new Date(courseB.createdAt ?? '').getTime() - new Date(courseA.createdAt ?? '').getTime()
);
}
const layoutType = widget.layoutType as TWidgetLayoutType;
const layoutOptions = normalizedConfig.layoutOptions;
// Hoist the configured "main" course to the front for layouts that have a hero slot,
// so the rest of the slice/limit logic can treat slot 0 as the featured course.
if (layoutType === 'primary_course' && layoutOptions.primaryCourse.featuredCourseId) {
const featuredId = layoutOptions.primaryCourse.featuredCourseId;
const featuredIndex = resolvedCourses.findIndex((course) => course.id === featuredId);
if (featuredIndex > 0) {
const [featuredCourse] = resolvedCourses.splice(featuredIndex, 1);
resolvedCourses = [featuredCourse, ...resolvedCourses];
}
} else if (layoutType === 'editorial_spotlight' && layoutOptions.editorialSpotlight.mainCourseId) {
const mainId = layoutOptions.editorialSpotlight.mainCourseId;
const mainIndex = resolvedCourses.findIndex((course) => course.id === mainId);
if (mainIndex > 0) {
const [mainCourse] = resolvedCourses.splice(mainIndex, 1);
resolvedCourses = [mainCourse, ...resolvedCourses];
}
}
// Cap the course list according to the active layout's limit. Category shelf
// builds its own grouped slices below from the already-filtered universe.
if (layoutType !== 'category_shelf') {
const layoutLimit = getLayoutCourseLimit(layoutType, layoutOptions);
resolvedCourses = resolvedCourses.slice(0, Math.max(layoutLimit, 1));
} else {
resolvedCourses = resolvedCourses.slice(0, getLayoutCourseLimit(layoutType, layoutOptions));
}
// Build payload-specific aggregates.
const heroCourseId =
layoutType === 'primary_course'
? (layoutOptions.primaryCourse.featuredCourseId ?? resolvedCourses[0]?.id ?? null)
: layoutType === 'editorial_spotlight'
? (layoutOptions.editorialSpotlight.mainCourseId ?? resolvedCourses[0]?.id ?? null)
: null;
const tagPool = buildTagPool(layoutType, normalizedConfig, resolvedCourses, tagsByCourseId);
const categories = buildCategoriesFromShelf(layoutType, normalizedConfig, resolvedCourses, tagsByCourseId);
const planGatedFields = resolveWidgetPlanGatedFields(activePlan?.planName, normalizedConfig.themePreset);
const sanitizedCustomCss = sanitizeCustomCssValue(normalizedConfig.advanced.customCss, activePlan?.planName);
const planGatedFields = resolveWidgetPlanGatedFields(planName, normalizedConfig.themePreset);
const sanitizedCustomCss = sanitizeCustomCssValue(normalizedConfig.advanced.customCss, planName);
const orgBaseUrl = getCourseBaseUrl(organization.siteName ?? '', organization.customDomain);
return ZWidgetPayload.parse({
version: 'v1',
widgetId: widget.id,
publicKey: widget.publicKey,
return buildWidgetPayloadShared({
widget: {
id: widget.id,
publicKey: widget.publicKey,
layoutType: widget.layoutType,
selectionMode: widget.selectionMode,
config: normalizedConfig
},
organization: {
id: organization.id,
name: organization.name,
siteName: organization.siteName,
siteName: organization.siteName ?? '',
customDomain: organization.customDomain
},
layoutType,
selectionMode: widget.selectionMode,
design: {
...normalizedConfig,
advanced: {
...normalizedConfig.advanced,
customCss: sanitizedCustomCss
}
},
orgBaseUrl,
allCourses,
selectedCourseIds,
planGatedFields,
labels: {
loadMoreLabel: normalizedConfig.content.loadMoreLabel,
poweredByLabel: 'Powered by Classroomio'
},
courses: resolvedCourses.map((course) => ({
id: course.id,
slug: course.slug ?? course.id,
title: course.title,
description: course.description,
imageUrl: course.bannerImage || course.logo || null,
price: course.cost ? `${course.currency} ${course.cost}` : 'Free',
lessonCount: course.lessonCount,
exerciseCount: course.exerciseCount ?? 0,
rating: null,
ratingCount: 0,
courseType: course.type,
createdAt: course.createdAt ?? new Date().toISOString(),
tags: tagsByCourseId[course.id] ?? [],
featured: heroCourseId ? course.id === heroCourseId : false,
url: `${orgBaseUrl}/course/${course.slug ?? course.id}`
})),
tagPool,
categories,
timestamp: Date.now()
planName,
sanitizedCustomCss
});
}
function buildTagPool(
layoutType: TWidgetLayoutType,
config: ReturnType<typeof normalizeWidgetConfig>,
courses: Array<{ id: string }>,
tagsByCourseId: Record<string, TWidgetPayloadCourseTag[]>
): TWidgetPayloadCourseTag[] {
if (layoutType !== 'tag_filter') {
return [];
}
const allTagsFromCourses = new Map<string, TWidgetPayloadCourseTag>();
for (const course of courses) {
for (const tag of tagsByCourseId[course.id] ?? []) {
if (!allTagsFromCourses.has(tag.id)) {
allTagsFromCourses.set(tag.id, tag);
}
}
}
if (config.layoutOptions.tagFilter.tagSource === 'manual') {
const allowedIds = new Set(config.layoutOptions.tagFilter.manualTagIds);
return [...allTagsFromCourses.values()].filter((tag) => allowedIds.has(tag.id));
}
return [...allTagsFromCourses.values()];
}
function buildCategoriesFromShelf(
layoutType: TWidgetLayoutType,
config: ReturnType<typeof normalizeWidgetConfig>,
courses: Array<{ id: string }>,
tagsByCourseId: Record<string, TWidgetPayloadCourseTag[]>
): TWidgetPayloadCategory[] {
if (layoutType !== 'category_shelf') {
return [];
}
const orderedCategoryIds = config.layoutOptions.categoryShelf.categoryTagIds;
if (orderedCategoryIds.length === 0) {
return [];
}
// Reverse-lookup tag info from the tags-by-course map.
const tagsById = new Map<string, TWidgetPayloadCourseTag>();
for (const tags of Object.values(tagsByCourseId)) {
for (const tag of tags) {
tagsById.set(tag.id, tag);
}
}
const maxPerCategory = config.layoutOptions.categoryShelf.maxPerCategory;
return orderedCategoryIds
.map((tagId) => {
const tagInfo = tagsById.get(tagId);
if (!tagInfo) {
return null;
}
const matchingCourses = courses.filter((course) =>
(tagsByCourseId[course.id] ?? []).some((tag) => tag.id === tagId)
);
return {
tagId,
name: tagInfo.name,
slug: tagInfo.slug,
color: tagInfo.color,
courseIds: matchingCourses.slice(0, maxPerCategory).map((course) => course.id)
} satisfies TWidgetPayloadCategory;
})
.filter((entry): entry is TWidgetPayloadCategory => entry !== null);
}
export async function listWidgetAvailableEditorData(orgId: string) {
const [coursesResult, tagGroups, activePlan] = await Promise.all([
getOrgCourses({ orgId, limit: 200 }),
@@ -406,20 +256,9 @@ export async function listWidgetAvailableEditorData(orgId: string) {
);
return {
availableCourses: coursesResult.items.map((course) => ({
id: course.id,
slug: course.slug ?? course.id,
title: course.title,
description: course.description,
imageUrl: course.bannerImage || course.logo || null,
isPublished: course.isPublished,
price: course.cost ? `${course.currency} ${course.cost}` : 'Free',
lessonCount: course.lessonCount,
exerciseCount: course.exerciseCount ?? 0,
courseType: course.type,
createdAt: course.createdAt ?? new Date().toISOString(),
tags: tagsByCourseId[course.id] ?? []
})),
availableCourses: coursesResult.items.map((course) =>
formatCourseForWidget(course, tagsByCourseId[course.id] ?? [])
),
availableTags: tagGroups.flatMap((group) =>
group.tags.map((tag) => ({
id: tag.id,
@@ -429,6 +268,7 @@ export async function listWidgetAvailableEditorData(orgId: string) {
groupId: group.id
}))
),
planGatedFields: resolveWidgetPlanGatedFields(activePlan?.planName, 'classroomio')
planGatedFields: resolveWidgetPlanGatedFields(activePlan?.planName, 'classroomio'),
planName: activePlan?.planName ?? null
};
}