feat: widgets and ai chat

This commit is contained in:
Rotimi Best
2026-04-27 11:40:38 +01:00
parent ae1801f85f
commit 5717dd8004
391 changed files with 28177 additions and 22467 deletions
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { normalizeAgentLessonContent } from '../services/agent/lesson-content';
describe('normalizeAgentLessonContent', () => {
it('removes a leading heading that duplicates the lesson title', () => {
const normalized = normalizeAgentLessonContent(
'<h3>Introduction to Functions</h3><p>Functions map inputs to outputs.</p>',
'Introduction to Functions'
);
expect(normalized).toBe('<p>Functions map inputs to outputs.</p>');
});
it('removes a leading bold paragraph that duplicates the lesson title', () => {
const normalized = normalizeAgentLessonContent(
'<p><strong>Introduction to Functions</strong></p><p>Functions map inputs to outputs.</p>',
'Introduction to Functions'
);
expect(normalized).toBe('<p>Functions map inputs to outputs.</p>');
});
it('rewrites h1 and h2 tags to h3', () => {
const normalized = normalizeAgentLessonContent(
'<h1>Overview</h1><h2>Key Ideas</h2><p>Body</p>',
'Different Lesson Title'
);
expect(normalized).toBe('<h3>Overview</h3><h3>Key Ideas</h3><p>Body</p>');
});
});
@@ -0,0 +1,232 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ContentType } from '@cio/utils/constants';
import { getCourseById } from '@cio/db/queries/course';
import { getCourseContentItems, type CourseContentItemRow } from '@cio/db/queries/course/content';
import { getCourseOrganizationId } from '@cio/db/queries/tag';
import { getOrganizationById } from '@cio/db/queries/organization';
import { updateCourse } from '../services/course/course';
import { ensureCourseSlug, generateUniqueCourseSlug } from '../services/course/landing-page';
import {
evaluateCourseGoLiveReadiness,
getCourseGoLiveReadiness,
publishCourseWhenReady
} from '../services/course/go-live-readiness';
vi.mock('@cio/db/queries/course', () => ({
getCourseById: vi.fn()
}));
vi.mock('@cio/db/queries/course/content', () => ({
getCourseContentItems: vi.fn()
}));
vi.mock('@cio/db/queries/tag', () => ({
getCourseOrganizationId: vi.fn()
}));
vi.mock('@cio/db/queries/organization', () => ({
getOrganizationById: vi.fn()
}));
vi.mock('../services/course/course', () => ({
updateCourse: vi.fn()
}));
vi.mock('../services/course/landing-page', () => ({
buildCourseBaseUrl: vi.fn(() => 'https://school.example.com'),
ensureCourseSlug: vi.fn(),
generateUniqueCourseSlug: vi.fn()
}));
const mockedGetCourseById = vi.mocked(getCourseById);
const mockedGetCourseContentItems = vi.mocked(getCourseContentItems);
const mockedGetCourseOrganizationId = vi.mocked(getCourseOrganizationId);
const mockedGetOrganizationById = vi.mocked(getOrganizationById);
const mockedUpdateCourse = vi.mocked(updateCourse);
const mockedEnsureCourseSlug = vi.mocked(ensureCourseSlug);
const mockedGenerateUniqueCourseSlug = vi.mocked(generateUniqueCourseSlug);
function buildCourse(overrides: Record<string, unknown> = {}) {
return {
id: 'course-1',
title: 'Workplace Safety Essentials',
description: 'A practical course for safer day-to-day workplace decisions.',
overview: 'Learn the policies, habits, and reporting steps that keep teams safe.',
slug: 'workplace-safety-essentials',
logo: 'https://example.com/banner.jpg',
bannerImage: null,
metadata: {
description: 'A public-facing overview of the safety course.',
goals: 'Identify hazards, report incidents, and follow safe operating procedures.',
requirements: 'No prior training required.',
instructor: {
name: 'Ada Instructor',
role: 'Safety Lead',
coursesNo: 4,
description: 'Experienced workplace safety instructor.',
imgUrl: ''
},
reviews: [
{
id: 1,
hide: false,
name: 'Learner',
avatar_url: '',
rating: 5,
created_at: 1,
description: 'Clear and useful.'
}
],
allowNewStudent: true
},
type: 'SELF_PACED',
cost: 0,
...overrides
};
}
function buildContentItem(overrides: Partial<CourseContentItemRow> = {}): CourseContentItemRow {
return {
id: 'lesson-1',
type: ContentType.Lesson,
title: 'Introduction',
order: 0,
createdAt: null,
sectionId: null,
isUnlocked: true,
isComplete: false,
lessonAt: null,
callUrl: null,
hasNoteContent: true,
hasSlideContent: false,
videosCount: 0,
documentsCount: 0,
questionCount: null,
dueBy: null,
...overrides
};
}
describe('evaluateCourseGoLiveReadiness', () => {
it('returns blockers for missing course, landing page, and content requirements', () => {
const readiness = evaluateCourseGoLiveReadiness({
course: buildCourse({
title: 'Untitled course',
description: '',
overview: 'Welcome to this amazing course',
slug: null,
logo: '',
metadata: { allowNewStudent: true }
}) as never,
contentItems: [],
organization: null
});
expect(readiness.ready).toBe(false);
expect(readiness.blockers.map((blocker) => blocker.code)).toEqual(
expect.arrayContaining([
'COURSE_TITLE_MISSING',
'COURSE_DESCRIPTION_MISSING',
'COURSE_SLUG_MISSING',
'LANDING_OVERVIEW_MISSING',
'LANDING_DESCRIPTION_MISSING',
'LANDING_GOALS_MISSING',
'LANDING_REQUIREMENTS_MISSING',
'LANDING_IMAGE_MISSING',
'COURSE_CONTENT_MISSING'
])
);
});
it('flags empty lessons and exercises without questions', () => {
const readiness = evaluateCourseGoLiveReadiness({
course: buildCourse() as never,
contentItems: [
buildContentItem({ hasNoteContent: false, hasSlideContent: false }),
buildContentItem({
id: 'exercise-1',
type: ContentType.Exercise,
title: 'Final quiz',
questionCount: 0
})
],
organization: null
});
expect(readiness.ready).toBe(false);
expect(readiness.blockers.map((blocker) => blocker.code)).toEqual(
expect.arrayContaining(['LESSON_CONTENT_EMPTY', 'EXERCISE_QUESTIONS_MISSING'])
);
});
it('returns ready when required fields and content are complete', () => {
const readiness = evaluateCourseGoLiveReadiness({
course: buildCourse() as never,
contentItems: [buildContentItem()],
organization: {
customDomain: 'school.example.com',
isCustomDomainVerified: true,
siteName: 'school'
} as never
});
expect(readiness.ready).toBe(true);
expect(readiness.blockers).toEqual([]);
expect(readiness.courseUrl).toBe('https://school.example.com/course/workplace-safety-essentials');
});
});
describe('course go-live service', () => {
beforeEach(() => {
vi.clearAllMocks();
mockedGetCourseOrganizationId.mockResolvedValue('org-1');
mockedGetOrganizationById.mockResolvedValue({
customDomain: 'school.example.com',
isCustomDomainVerified: true,
siteName: 'school'
} as never);
mockedGenerateUniqueCourseSlug.mockResolvedValue('workplace-safety-essentials');
});
it('suggests a unique slug when a ready course has no slug', async () => {
mockedGetCourseById.mockResolvedValue([buildCourse({ slug: null })] as never);
mockedGetCourseContentItems.mockResolvedValue([buildContentItem()] as never);
const readiness = await getCourseGoLiveReadiness('course-1');
expect(readiness.ready).toBe(true);
expect(readiness.suggestedFixes.slug).toBe('workplace-safety-essentials');
expect(readiness.courseUrl).toBe('https://school.example.com/course/workplace-safety-essentials');
expect(mockedGenerateUniqueCourseSlug).toHaveBeenCalledWith('workplace-safety-essentials');
});
it('does not publish when readiness blockers remain', async () => {
mockedGetCourseById.mockResolvedValue([buildCourse({ description: '' })] as never);
mockedGetCourseContentItems.mockResolvedValue([buildContentItem()] as never);
await expect(publishCourseWhenReady('course-1')).rejects.toThrow('Course is not ready to go live');
expect(mockedUpdateCourse).not.toHaveBeenCalled();
});
it('generates a slug and publishes when the course is ready', async () => {
const course = buildCourse({ slug: null });
mockedGetCourseById.mockResolvedValue([course] as never);
mockedGetCourseContentItems.mockResolvedValue([buildContentItem()] as never);
mockedEnsureCourseSlug.mockResolvedValue('workplace-safety-essentials');
mockedUpdateCourse.mockResolvedValue({
...course,
slug: 'workplace-safety-essentials',
isPublished: true
} as never);
const result = await publishCourseWhenReady('course-1');
expect(mockedEnsureCourseSlug).toHaveBeenCalledWith('course-1', course.title);
expect(mockedUpdateCourse).toHaveBeenCalledWith('course-1', {
slug: 'workplace-safety-essentials',
isPublished: true
});
expect(result.course.isPublished).toBe(true);
expect(result.readiness.ready).toBe(true);
});
});
@@ -0,0 +1,409 @@
import { describe, expect, it } from 'vitest';
import {
buildUpdatedQuestions,
type CurrentQuestionForPatch,
type QuestionPatch
} from '@api/services/agent/question-update';
import { QUESTION_TYPE } from '@cio/utils/validation/constants';
const EXERCISE_ID = 'ex-1';
function makeQuestion(overrides: Partial<CurrentQuestionForPatch>): CurrentQuestionForPatch {
return {
id: 1,
title: 'Existing question',
questionTypeId: QUESTION_TYPE.RADIO,
points: 1,
order: 0,
settings: {},
options: [],
...overrides
};
}
describe('buildUpdatedQuestions', () => {
describe('option-based question types (correct answer on options[].isCorrect)', () => {
it('RADIO: flips the correct option without losing siblings', () => {
const current = makeQuestion({
questionTypeId: QUESTION_TYPE.RADIO,
options: [
{ id: 10, label: 'A', isCorrect: true },
{ id: 11, label: 'B', isCorrect: false },
{ id: 12, label: 'C', isCorrect: false }
]
});
const patch: QuestionPatch = {
id: 1,
options: [
{ id: 10, isCorrect: false },
{ id: 11, isCorrect: true },
{ id: 12, isCorrect: false }
]
};
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
expect(result.options).toEqual([
{ id: 10, label: 'A', isCorrect: false, settings: undefined },
{ id: 11, label: 'B', isCorrect: true, settings: undefined },
{ id: 12, label: 'C', isCorrect: false, settings: undefined }
]);
});
it('CHECKBOX: can mark multiple options correct in a single patch', () => {
const current = makeQuestion({
questionTypeId: QUESTION_TYPE.CHECKBOX,
options: [
{ id: 20, label: 'X', isCorrect: false },
{ id: 21, label: 'Y', isCorrect: false },
{ id: 22, label: 'Z', isCorrect: false }
]
});
const patch: QuestionPatch = {
id: 1,
options: [
{ id: 20, isCorrect: true },
{ id: 21, isCorrect: true },
{ id: 22, isCorrect: false }
]
};
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
expect(result.options.filter((o) => o.isCorrect).map((o) => o.id)).toEqual([20, 21]);
});
it('TRUE_FALSE: switches the correct side', () => {
const current = makeQuestion({
questionTypeId: QUESTION_TYPE.TRUE_FALSE,
options: [
{ id: 30, label: 'True', isCorrect: true },
{ id: 31, label: 'False', isCorrect: false }
]
});
const patch: QuestionPatch = {
id: 1,
options: [
{ id: 30, isCorrect: false },
{ id: 31, isCorrect: true }
]
};
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
const correct = result.options.find((o) => o.isCorrect);
expect(correct?.label).toBe('False');
});
it('appends a new option when id is omitted (RADIO)', () => {
const current = makeQuestion({
questionTypeId: QUESTION_TYPE.RADIO,
options: [
{ id: 40, label: 'A', isCorrect: true },
{ id: 41, label: 'B', isCorrect: false }
]
});
const patch: QuestionPatch = {
id: 1,
options: [
{ id: 40, isCorrect: true },
{ id: 41, isCorrect: false },
{ label: 'C', isCorrect: false }
]
};
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
expect(result.options).toHaveLength(3);
expect(result.options[2]).toEqual({ label: 'C', isCorrect: false, settings: undefined });
expect(result.options[2].id).toBeUndefined();
});
});
describe('settings-based question types (correct answer on question.settings)', () => {
it('NUMERIC: writes settings.correctValue and preserves existing tolerance', () => {
const current = makeQuestion({
questionTypeId: QUESTION_TYPE.NUMERIC,
settings: { tolerance: 0.1, hint: 'a ray' }
});
const patch: QuestionPatch = {
id: 1,
settings: { correctValue: 1 }
};
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
expect(result.settings).toEqual({ tolerance: 0.1, hint: 'a ray', correctValue: 1 });
expect(result.options).toEqual([]);
});
it('NUMERIC: patching tolerance does not clobber correctValue', () => {
const current = makeQuestion({
questionTypeId: QUESTION_TYPE.NUMERIC,
settings: { correctValue: 42, tolerance: 0 }
});
const patch: QuestionPatch = {
id: 1,
settings: { tolerance: 0.5 }
};
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
expect(result.settings).toEqual({ correctValue: 42, tolerance: 0.5 });
});
it('STAR: writes settings.correctValue', () => {
const current = makeQuestion({
questionTypeId: QUESTION_TYPE.STAR,
settings: { maxStars: 5 }
});
const patch: QuestionPatch = {
id: 1,
settings: { correctValue: 4 }
};
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
expect(result.settings).toEqual({ maxStars: 5, correctValue: 4 });
});
it('WORD_BANK: writes correctAnswers and template together', () => {
const current = makeQuestion({
questionTypeId: QUESTION_TYPE.WORD_BANK,
settings: { template: 'A ___ has no end.', correctAnswers: ['line'] }
});
const patch: QuestionPatch = {
id: 1,
settings: {
template: 'A ___ extends in one direction; a ___ extends in both.',
correctAnswers: ['ray', 'line']
}
};
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
expect(result.settings).toEqual({
template: 'A ___ extends in one direction; a ___ extends in both.',
correctAnswers: ['ray', 'line']
});
});
it('FILL_BLANK: settings shallow-merge preserves untouched keys', () => {
const current = makeQuestion({
questionTypeId: QUESTION_TYPE.FILL_BLANK,
settings: { caseSensitive: false, correctAnswers: ['old'] }
});
const patch: QuestionPatch = {
id: 1,
settings: { correctAnswers: ['new'] }
};
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
expect(result.settings).toEqual({ caseSensitive: false, correctAnswers: ['new'] });
});
it('MATCHING: settings shallow-merge preserves pairs not in patch', () => {
const current = makeQuestion({
questionTypeId: QUESTION_TYPE.MATCHING,
settings: { pairs: [{ left: 'A', right: '1' }] }
});
const patch: QuestionPatch = {
id: 1,
settings: {
pairs: [
{ left: 'A', right: '1' },
{ left: 'B', right: '2' }
]
}
};
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
expect(result.settings).toEqual({
pairs: [
{ left: 'A', right: '1' },
{ left: 'B', right: '2' }
]
});
});
it('ORDERING: writes settings.correctOrder', () => {
const current = makeQuestion({
questionTypeId: QUESTION_TYPE.ORDERING,
settings: { items: ['x', 'y', 'z'] }
});
const patch: QuestionPatch = {
id: 1,
settings: { correctOrder: ['z', 'y', 'x'] }
};
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
expect(result.settings).toEqual({ items: ['x', 'y', 'z'], correctOrder: ['z', 'y', 'x'] });
});
it('HOTSPOT: writes settings.correctRegion', () => {
const current = makeQuestion({
questionTypeId: QUESTION_TYPE.HOTSPOT,
settings: { imageUrl: 'https://example.com/img.png' }
});
const patch: QuestionPatch = {
id: 1,
settings: { correctRegion: { x: 10, y: 20, w: 30, h: 30 } }
};
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
expect(result.settings).toEqual({
imageUrl: 'https://example.com/img.png',
correctRegion: { x: 10, y: 20, w: 30, h: 30 }
});
});
});
describe('manually-graded / answer-free types', () => {
it.each([
['TEXTAREA', QUESTION_TYPE.TEXTAREA],
['SHORT_ANSWER', QUESTION_TYPE.SHORT_ANSWER],
['FILE_UPLOAD', QUESTION_TYPE.FILE_UPLOAD],
['LINK', QUESTION_TYPE.LINK]
])('%s: updates points and prompt without touching options', (_, questionTypeId) => {
const current = makeQuestion({
questionTypeId,
title: 'Old prompt',
points: 1,
settings: { rubric: 'be specific' }
});
const patch: QuestionPatch = {
id: 1,
question: 'New prompt',
points: 5
};
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
expect(result.question).toBe('New prompt');
expect(result.points).toBe(5);
expect(result.settings).toEqual({ rubric: 'be specific' });
expect(result.options).toEqual([]);
});
});
describe('safety guarantees', () => {
it('throws when the patched id is not in the exercise', () => {
const current = makeQuestion({ id: 1 });
expect(() => buildUpdatedQuestions([current], [{ id: 999, points: 5 }], EXERCISE_ID)).toThrowError(
/does not belong to exercise/
);
});
it('omitting options preserves existing options (avoids the diff-deletes-all trap)', () => {
const current = makeQuestion({
questionTypeId: QUESTION_TYPE.RADIO,
options: [
{ id: 50, label: 'A', isCorrect: true },
{ id: 51, label: 'B', isCorrect: false }
]
});
const patch: QuestionPatch = { id: 1, points: 3 };
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
expect(result.options).toEqual([
{ id: 50, label: 'A', isCorrect: true, settings: undefined },
{ id: 51, label: 'B', isCorrect: false, settings: undefined }
]);
});
it('passing options drops any existing option missing from the array (downstream diff will delete it)', () => {
const current = makeQuestion({
questionTypeId: QUESTION_TYPE.RADIO,
options: [
{ id: 60, label: 'A', isCorrect: true },
{ id: 61, label: 'B', isCorrect: false },
{ id: 62, label: 'C', isCorrect: false }
]
});
const patch: QuestionPatch = {
id: 1,
options: [
{ id: 60, isCorrect: true },
{ id: 61, isCorrect: false }
]
};
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
expect(result.options.map((o) => o.id)).toEqual([60, 61]);
});
it('omitting question/points/order/typeId backfills from current question', () => {
const current = makeQuestion({
title: 'Original',
questionTypeId: QUESTION_TYPE.NUMERIC,
points: 7,
order: 3,
settings: { correctValue: 5 }
});
const patch: QuestionPatch = { id: 1, settings: { correctValue: 9 } };
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
expect(result.question).toBe('Original');
expect(result.questionTypeId).toBe(QUESTION_TYPE.NUMERIC);
expect(result.points).toBe(7);
expect(result.order).toBe(3);
expect(result.settings).toEqual({ correctValue: 9 });
});
it('per-option settings shallow-merge logic: patch overrides; existing label/isCorrect preserved when omitted', () => {
const current = makeQuestion({
questionTypeId: QUESTION_TYPE.RADIO,
options: [{ id: 70, label: 'Keep label', isCorrect: true, settings: { color: 'red' } }]
});
const patch: QuestionPatch = {
id: 1,
options: [{ id: 70, settings: { color: 'blue' } }]
};
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
expect(result.options[0]).toEqual({
id: 70,
label: 'Keep label',
isCorrect: true,
settings: { color: 'blue' }
});
});
it('handles multiple patches in one call independently', () => {
const current: CurrentQuestionForPatch[] = [
makeQuestion({ id: 1, questionTypeId: QUESTION_TYPE.NUMERIC, settings: { correctValue: 1 } }),
makeQuestion({
id: 2,
questionTypeId: QUESTION_TYPE.RADIO,
options: [
{ id: 80, label: 'A', isCorrect: false },
{ id: 81, label: 'B', isCorrect: true }
]
})
];
const patches: QuestionPatch[] = [
{ id: 1, settings: { correctValue: 42 } },
{
id: 2,
options: [
{ id: 80, isCorrect: true },
{ id: 81, isCorrect: false }
]
}
];
const result = buildUpdatedQuestions(current, patches, EXERCISE_ID);
expect(result[0].settings).toEqual({ correctValue: 42 });
expect(result[1].options.find((o) => o.isCorrect)?.id).toBe(80);
});
});
});
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import {
buildWidgetGoogleFontsStylesheetHref,
collectWidgetGoogleFontFamilies,
getDefaultWidgetConfig,
getWidgetThemeSerifStack,
WIDGET_THEME_PRESET_VALUES
} from '@cio/utils/validation/widget';
describe('Widget Google Fonts', () => {
it('loads theme-specific families for each preset', () => {
for (const theme of WIDGET_THEME_PRESET_VALUES) {
const config = { ...getDefaultWidgetConfig(), themePreset: theme };
const families = collectWidgetGoogleFontFamilies(config);
expect(families.length).toBeGreaterThan(0);
const href = buildWidgetGoogleFontsStylesheetHref(config);
expect(href.startsWith('https://fonts.googleapis.com/css2?')).toBe(true);
expect(href).toContain('display=swap');
}
});
it('includes IBM Plex families for graphite', () => {
const config = { ...getDefaultWidgetConfig(), themePreset: 'graphite' as const };
const families = collectWidgetGoogleFontFamilies(config);
expect(families).toContain('IBM Plex Sans');
expect(families).toContain('IBM Plex Serif');
});
it('merges quoted custom Google fonts from typography.fontFamily', () => {
const config = {
...getDefaultWidgetConfig(),
themePreset: 'classroomio' as const,
typography: {
...getDefaultWidgetConfig().typography,
fontFamily: "'Manrope', ui-sans-serif, sans-serif"
}
};
expect(collectWidgetGoogleFontFamilies(config)).toContain('Manrope');
});
it('exposes serif stacks per theme', () => {
expect(getWidgetThemeSerifStack('classroomio')).toContain('Lora');
expect(getWidgetThemeSerifStack('spruce')).toContain('Fraunces');
});
});
@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest';
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
/** Mirrors the key generation logic from widget-payload.ts for isolated testing. */
function encodeBase58(bytes: Uint8Array): string {
let value = 0n;
for (const byte of bytes) {
value = (value << 8n) + BigInt(byte);
}
if (value === 0n) {
return BASE58_ALPHABET[0];
}
let encoded = '';
while (value > 0n) {
const remainder = Number(value % 58n);
encoded = BASE58_ALPHABET[remainder] + encoded;
value /= 58n;
}
return encoded;
}
function generateWidgetPublicKey(): string {
const bytes = crypto.getRandomValues(new Uint8Array(16));
return `wgt_${encodeBase58(bytes).slice(0, 16)}`;
}
function getWidgetEmbedCode(publicKey: string): string {
const scriptUrl = 'https://assets.cdn.clsrio.com/embeds/course-widget/course-widget.js';
return `<div data-cio-widget="course-widget" data-widget-key="${publicKey}"></div>\n<script async type="module" src="${scriptUrl}"></script>`;
}
describe('generateWidgetPublicKey', () => {
it('should start with wgt_ prefix', () => {
const key = generateWidgetPublicKey();
expect(key.startsWith('wgt_')).toBe(true);
});
it('should have expected length', () => {
const key = generateWidgetPublicKey();
expect(key.length).toBe(20); // 'wgt_' + 16 chars
});
it('should generate unique keys', () => {
const keys = new Set(Array.from({ length: 50 }, () => generateWidgetPublicKey()));
expect(keys.size).toBe(50);
});
it('should only contain base58 characters after prefix', () => {
const key = generateWidgetPublicKey();
const suffix = key.slice(4);
const base58Regex = /^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$/;
expect(base58Regex.test(suffix)).toBe(true);
});
});
describe('encodeBase58', () => {
it('should encode zero bytes to 1', () => {
expect(encodeBase58(new Uint8Array([0]))).toBe('1');
});
it('should produce deterministic output', () => {
const bytes = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]);
const resultA = encodeBase58(bytes);
const resultB = encodeBase58(bytes);
expect(resultA).toBe(resultB);
});
it('should not contain ambiguous characters (0, O, I, l)', () => {
const encoded = BASE58_ALPHABET;
expect(encoded).not.toContain('0');
expect(encoded).not.toContain('O');
expect(encoded).not.toContain('I');
expect(encoded).not.toContain('l');
});
});
describe('getWidgetEmbedCode', () => {
it('should contain the public key in data attribute', () => {
const embedCode = getWidgetEmbedCode('wgt_testkey123456');
expect(embedCode).toContain('data-widget-key="wgt_testkey123456"');
});
it('should contain the widget type data attribute', () => {
const embedCode = getWidgetEmbedCode('wgt_testkey123456');
expect(embedCode).toContain('data-cio-widget="course-widget"');
});
it('should contain an async module script tag', () => {
const embedCode = getWidgetEmbedCode('wgt_testkey123456');
expect(embedCode).toContain('<script async type="module"');
});
it('should reference the CDN script URL', () => {
const embedCode = getWidgetEmbedCode('wgt_testkey123456');
expect(embedCode).toContain('course-widget.js');
});
it('should properly escape special characters in public key', () => {
// Public keys are base58, so no special HTML chars, but verify structure
const embedCode = getWidgetEmbedCode('wgt_abc123def456gh');
expect(embedCode).toMatch(/^<div[^>]+><\/div>\n<script[^>]+><\/script>$/);
});
});
@@ -0,0 +1,371 @@
import { describe, expect, it } from 'vitest';
import {
ZCreateWidget,
ZUpdateWidget,
ZWidgetConfig,
ZWidgetPayload,
ZWidgetPublicKeyParams,
getDefaultWidgetConfig,
getLayoutCourseLimit,
normalizeWidgetConfig,
resolveWidgetPlanGatedFields,
isPaidWidgetPlan,
WIDGET_LAYOUT_TYPE_VALUES,
WIDGET_THEME_PRESET_VALUES
} from '@cio/utils/validation/widget';
import type { TWidgetConfig } from '@cio/utils/validation/widget';
import { PLAN } from '@cio/utils/plans';
const SAMPLE_UUID = '11111111-1111-4111-8111-111111111111';
const ANOTHER_UUID = '22222222-2222-4222-8222-222222222222';
describe('Widget Validation Schemas', () => {
describe('ZCreateWidget', () => {
it('should parse valid create input', () => {
const result = ZCreateWidget.safeParse({
name: 'My Widget',
layoutType: 'card_grid',
selectionMode: 'manual',
selectedCourseIds: []
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.name).toBe('My Widget');
expect(result.data.layoutType).toBe('card_grid');
}
});
it('should apply defaults for optional fields', () => {
const result = ZCreateWidget.safeParse({ name: 'Test' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.layoutType).toBe('card_grid');
expect(result.data.selectionMode).toBe('manual');
expect(result.data.selectedCourseIds).toEqual([]);
}
});
it('should reject empty name', () => {
const result = ZCreateWidget.safeParse({ name: '' });
expect(result.success).toBe(false);
});
it('should reject name longer than 120 characters', () => {
const result = ZCreateWidget.safeParse({ name: 'a'.repeat(121) });
expect(result.success).toBe(false);
});
it('should reject invalid layout type', () => {
const result = ZCreateWidget.safeParse({ name: 'Test', layoutType: 'invalid' });
expect(result.success).toBe(false);
});
it('should accept all valid layout types', () => {
for (const layoutType of WIDGET_LAYOUT_TYPE_VALUES) {
const baseInput: Record<string, unknown> = {
name: 'Test',
layoutType,
selectionMode: 'published'
};
if (layoutType === 'primary_course') {
baseInput.config = {
layoutOptions: { primaryCourse: { featuredCourseId: SAMPLE_UUID } }
};
} else if (layoutType === 'editorial_spotlight') {
baseInput.config = {
layoutOptions: { editorialSpotlight: { mainCourseId: SAMPLE_UUID } }
};
} else if (layoutType === 'category_shelf') {
baseInput.config = {
layoutOptions: { categoryShelf: { categoryTagIds: [SAMPLE_UUID, ANOTHER_UUID] } }
};
}
const result = ZCreateWidget.safeParse(baseInput);
expect(result.success, `${layoutType} should be valid: ${JSON.stringify(result.error?.issues)}`).toBe(true);
}
});
it('should reject primary_course layout without featuredCourseId', () => {
const result = ZCreateWidget.safeParse({ name: 'Test', layoutType: 'primary_course' });
expect(result.success).toBe(false);
});
it('should reject editorial_spotlight layout without mainCourseId', () => {
const result = ZCreateWidget.safeParse({ name: 'Test', layoutType: 'editorial_spotlight' });
expect(result.success).toBe(false);
});
it('should accept category_shelf with manual selection and empty categoryTagIds', () => {
const result = ZCreateWidget.safeParse({
name: 'Test',
layoutType: 'category_shelf',
selectionMode: 'manual',
selectedCourseIds: [SAMPLE_UUID]
});
expect(result.success).toBe(true);
});
it('should reject category_shelf with published selection and empty categoryTagIds', () => {
const result = ZCreateWidget.safeParse({
name: 'Test',
layoutType: 'category_shelf',
selectionMode: 'published'
});
expect(result.success).toBe(false);
});
});
describe('ZUpdateWidget', () => {
it('should parse valid partial update', () => {
const result = ZUpdateWidget.safeParse({ name: 'Updated Name' });
expect(result.success).toBe(true);
});
it('should accept empty object', () => {
const result = ZUpdateWidget.safeParse({});
expect(result.success).toBe(true);
});
it('should reject empty name string', () => {
const result = ZUpdateWidget.safeParse({ name: '' });
expect(result.success).toBe(false);
});
});
describe('ZWidgetConfig', () => {
it('should parse empty object with defaults', () => {
const result = ZWidgetConfig.safeParse({});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.themePreset).toBe('classroomio');
expect(result.data.sortBy).toBe('manual');
expect(result.data.colors.primaryColor).toBe('#0f172a');
expect(result.data.layoutOptions.cardGrid.maxCount).toBe(9);
expect(result.data.layoutOptions.tagFilter.maxCount).toBe(12);
}
});
it('should reject custom CSS exceeding 5KB', () => {
const result = ZWidgetConfig.safeParse({
advanced: { customCss: 'a'.repeat(5001) }
});
expect(result.success).toBe(false);
});
it('should accept custom CSS within 5KB', () => {
const result = ZWidgetConfig.safeParse({
advanced: { customCss: '.widget { color: red; }' }
});
expect(result.success).toBe(true);
});
it('should reject invalid theme preset', () => {
const result = ZWidgetConfig.safeParse({ themePreset: 'invalid' });
expect(result.success).toBe(false);
});
it('should reject font scale below 0.8', () => {
const result = ZWidgetConfig.safeParse({
typography: { fontFamily: 'sans-serif', fontSizeScale: 0.5 }
});
expect(result.success).toBe(false);
});
it('should reject font scale above 1.4', () => {
const result = ZWidgetConfig.safeParse({
typography: { fontFamily: 'sans-serif', fontSizeScale: 2.0 }
});
expect(result.success).toBe(false);
});
});
describe('ZWidgetPublicKeyParams', () => {
it('should accept valid public key', () => {
const result = ZWidgetPublicKeyParams.safeParse({ publicKey: 'wgt_3J98t1WpEZ73CN' });
expect(result.success).toBe(true);
});
it('should reject short public key', () => {
const result = ZWidgetPublicKeyParams.safeParse({ publicKey: 'wgt' });
expect(result.success).toBe(false);
});
it('should reject empty public key', () => {
const result = ZWidgetPublicKeyParams.safeParse({ publicKey: '' });
expect(result.success).toBe(false);
});
});
});
describe('getDefaultWidgetConfig', () => {
it('should return valid config', () => {
const config = getDefaultWidgetConfig();
const result = ZWidgetConfig.safeParse(config);
expect(result.success).toBe(true);
});
it('should return consistent defaults', () => {
const configA = getDefaultWidgetConfig();
const configB = getDefaultWidgetConfig();
expect(configA).toEqual(configB);
});
it('should expose layoutOptions for every layout type', () => {
const config = getDefaultWidgetConfig();
expect(Object.keys(config.layoutOptions)).toEqual(
expect.arrayContaining([
'cardGrid',
'tagFilter',
'carousel',
'primaryCourse',
'compactList',
'editorialSpotlight',
'categoryShelf'
])
);
});
});
describe('getLayoutCourseLimit', () => {
it('should respect per-layout maxCount values', () => {
const config = getDefaultWidgetConfig();
expect(getLayoutCourseLimit('card_grid', config.layoutOptions)).toBe(9);
expect(getLayoutCourseLimit('tag_filter', config.layoutOptions)).toBe(12);
expect(getLayoutCourseLimit('carousel', config.layoutOptions)).toBe(6);
expect(getLayoutCourseLimit('compact_list', config.layoutOptions)).toBe(5);
});
it('should sum primary course slots', () => {
const config = getDefaultWidgetConfig();
config.layoutOptions.primaryCourse.secondaryMaxCount = 3;
expect(getLayoutCourseLimit('primary_course', config.layoutOptions)).toBe(4);
});
it('should sum editorial spotlight slots', () => {
const config = getDefaultWidgetConfig();
config.layoutOptions.editorialSpotlight.secondaryMaxCount = 4;
expect(getLayoutCourseLimit('editorial_spotlight', config.layoutOptions)).toBe(5);
});
it('should compute category shelf via tags × maxPerCategory', () => {
const config = getDefaultWidgetConfig();
config.layoutOptions.categoryShelf.categoryTagIds = [SAMPLE_UUID, ANOTHER_UUID];
config.layoutOptions.categoryShelf.maxPerCategory = 6;
expect(getLayoutCourseLimit('category_shelf', config.layoutOptions)).toBe(12);
});
});
describe('isPaidWidgetPlan', () => {
it('should return false for BASIC plan', () => {
expect(isPaidWidgetPlan(PLAN.BASIC)).toBe(false);
});
it('should return true for EARLY_ADOPTER plan', () => {
expect(isPaidWidgetPlan(PLAN.EARLY_ADOPTER)).toBe(true);
});
it('should return true for ENTERPRISE plan', () => {
expect(isPaidWidgetPlan(PLAN.ENTERPRISE)).toBe(true);
});
it('should return false for null/undefined', () => {
expect(isPaidWidgetPlan(null)).toBe(false);
expect(isPaidWidgetPlan(undefined)).toBe(false);
});
});
describe('resolveWidgetPlanGatedFields', () => {
it('should restrict free plan to one theme', () => {
const fields = resolveWidgetPlanGatedFields(PLAN.BASIC);
expect(fields.isPaidPlan).toBe(false);
expect(fields.availableThemes).toEqual(['classroomio']);
expect(fields.canUseCustomColors).toBe(false);
expect(fields.canUseCustomCss).toBe(false);
expect(fields.isBrandingForced).toBe(true);
});
it('should unlock all themes for paid plan', () => {
const fields = resolveWidgetPlanGatedFields(PLAN.EARLY_ADOPTER);
expect(fields.isPaidPlan).toBe(true);
expect(fields.availableThemes).toEqual([...WIDGET_THEME_PRESET_VALUES]);
expect(fields.canUseCustomColors).toBe(true);
expect(fields.canUseCustomCss).toBe(true);
expect(fields.isBrandingForced).toBe(false);
});
it('should fallback invalid theme to classroomio on free plan', () => {
const fields = resolveWidgetPlanGatedFields(PLAN.BASIC, 'graphite');
expect(fields.selectedTheme).toBe('classroomio');
});
it('should allow selected theme on paid plan', () => {
const fields = resolveWidgetPlanGatedFields(PLAN.EARLY_ADOPTER, 'spruce');
expect(fields.selectedTheme).toBe('spruce');
});
});
describe('normalizeWidgetConfig', () => {
it('should enforce branding for free plan', () => {
const config = normalizeWidgetConfig({ branding: { showPoweredBy: false } }, PLAN.BASIC);
expect(config.branding.showPoweredBy).toBe(true);
});
it('should allow branding toggle for paid plan', () => {
const config = normalizeWidgetConfig({ branding: { showPoweredBy: false } }, PLAN.EARLY_ADOPTER);
expect(config.branding.showPoweredBy).toBe(false);
});
it('should strip custom CSS for free plan', () => {
const config = normalizeWidgetConfig({ advanced: { customCss: '.foo { color: red; }' } }, PLAN.BASIC);
expect(config.advanced.customCss).toBe('');
});
it('should keep custom CSS for paid plan', () => {
const config = normalizeWidgetConfig({ advanced: { customCss: '.foo { color: red; }' } }, PLAN.EARLY_ADOPTER);
expect(config.advanced.customCss).toBe('.foo { color: red; }');
});
it('should reset colors to defaults for free plan', () => {
const config = normalizeWidgetConfig(
{
colors: {
primaryColor: '#ff0000',
backgroundColor: '#ffffff',
textColor: '#111827',
badgeColor: '#e2e8f0',
borderColor: '#e5e7eb',
highlightColor: '#dbeafe'
}
} as Partial<TWidgetConfig>,
PLAN.BASIC
);
const defaultConfig = getDefaultWidgetConfig();
expect(config.colors).toEqual(defaultConfig.colors);
});
it('should allow custom colors for paid plan', () => {
const config = normalizeWidgetConfig(
{
colors: {
primaryColor: '#ff0000',
backgroundColor: '#ffffff',
textColor: '#111827',
badgeColor: '#e2e8f0',
borderColor: '#e5e7eb',
highlightColor: '#dbeafe'
}
} as Partial<TWidgetConfig>,
PLAN.EARLY_ADOPTER
);
expect(config.colors.primaryColor).toBe('#ff0000');
});
it('should handle null/undefined config input', () => {
const config = normalizeWidgetConfig(null);
expect(config).toBeDefined();
expect(config.themePreset).toBe('classroomio');
});
});
+20
View File
@@ -22,12 +22,22 @@ import { organizationSsoRouter } from '@api/routes/organization/sso';
import { organizationTokenAuthRouter } from '@api/routes/organization/token-auth';
import { v1Router } from '@api/routes/v1';
import { ssoDiscoveryRouter } from '@api/routes/sso/discovery';
import { publicWidgetsRouter } from '@api/routes/widgets';
import { prettyJSON } from 'hono/pretty-json';
import rateLimiter from '@api/middlewares/rate-limiter';
import { secureHeaders } from 'hono/secure-headers';
import { signupGuard } from '@api/middlewares/signup-guard';
import { programRouter } from '@api/routes/program';
import { unsplashRouter } from '@api/routes/unsplash/unsplash';
import {
agentStatusRouter,
agentChatRouter,
agentUploadRouter,
agentUsageRouter,
agentCreditsRouter,
agentHistoryRouter
} from '@api/routes/agent';
import { internalComplianceRouter } from '@api/routes/internal/compliance';
// Create Hono app with chaining for RPC support
export const app = new Hono()
@@ -53,6 +63,7 @@ export const app = new Hono()
if (!session) {
c.set('user', null);
c.set('session', null);
c.set('orgRoles', {});
await next();
return;
@@ -60,6 +71,7 @@ export const app = new Hono()
c.set('user', session.user);
c.set('session', session.session);
c.set('orgRoles', (session as { orgRoles?: Record<string, number> }).orgRoles ?? {});
await next();
})
@@ -99,6 +111,14 @@ export const app = new Hono()
.route('/public-api/v1', v1Router)
.route('/program', programRouter)
.route('/unsplash', unsplashRouter)
.route('/widgets', publicWidgetsRouter)
.route('/internal/compliance', internalComplianceRouter)
.route('/agent', agentStatusRouter)
.route('/agent', agentChatRouter)
.route('/agent', agentUploadRouter)
.route('/agent', agentUsageRouter)
.route('/agent', agentCreditsRouter)
.route('/agent', agentHistoryRouter)
// Error handling
.onError((err, c) => {
+1
View File
@@ -29,6 +29,7 @@ const envSchema = z.object({
OPENAPI_URL: z.string().optional(),
PORT: z.string().optional(),
REDIS_URL: z.string().optional(),
PUBLIC_EMBED_BASE_URL: z.string().optional(),
PUBLIC_SERVER_URL: z.string().optional(),
SMTP_HOST: z.string().optional(),
SMTP_PASSWORD: z.string().optional(),
+2 -1
View File
@@ -6,8 +6,9 @@ export const API_PORT = env.PORT ? parseInt(env.PORT) : 3002;
export const API_SERVER_URL = env.PUBLIC_SERVER_URL || `http://localhost:${API_PORT}`;
export const TRUSTED_ORIGINS = env.TRUSTED_ORIGINS
? env.TRUSTED_ORIGINS.split(',')
: ['http://localhost:5173', 'http://*.classroomio.*'];
: ['http://localhost:5173', 'http://localhost:5180', 'http://*.classroomio.*'];
console.log('TRUSTED_ORIGINSs', TRUSTED_ORIGINS);
const {
CLOUDFLARE_BUCKET_DOMAIN,
CLOUDFLARE_ACCESS_KEY,
@@ -2,7 +2,7 @@ import { Context, Next } from 'hono';
import type { TOrganizationApiKeyScope } from '@cio/utils/validation/organization';
import { ErrorCodes } from '@api/utils/errors';
import { isUserOrgAdmin } from '@cio/db/queries/organization';
import { ROLE } from '@cio/utils/constants';
import { organizationApiKeyHasScopes } from '@api/services/organization/automation-key';
export const orgAdminOrAutomationKeyMiddleware =
@@ -55,8 +55,8 @@ export const orgAdminOrAutomationKeyMiddleware =
);
}
const isAdmin = await isUserOrgAdmin(orgId, user.id);
if (!isAdmin) {
const orgRoles = (c.get('orgRoles') as Record<string, number> | undefined) ?? {};
if (orgRoles[orgId] !== ROLE.ADMIN) {
return c.json(
{
success: false,
+11 -7
View File
@@ -1,12 +1,15 @@
import { Context, Next } from 'hono';
import { ErrorCodes } from '@api/utils/errors';
import { isUserOrgAdmin } from '@cio/db/queries/organization';
import { ROLE } from '@cio/utils/constants';
/**
* Middleware to check if the authenticated user is an admin of the organization
* Requires authMiddleware to be applied first
* Expects orgId in the 'cio-org-id' header
* Middleware to check if the authenticated user is an admin of the organization.
* Reads the role from the Better Auth session (populated by the customSession plugin),
* so no per-request DB query is performed.
*
* Requires authMiddleware to be applied first.
* Expects orgId in the 'cio-org-id' header.
*/
export const orgAdminMiddleware = async (c: Context, next: Next) => {
try {
@@ -23,7 +26,6 @@ export const orgAdminMiddleware = async (c: Context, next: Next) => {
}
const orgId = c.req.header('cio-org-id');
if (!orgId) {
return c.json(
{
@@ -35,8 +37,10 @@ export const orgAdminMiddleware = async (c: Context, next: Next) => {
);
}
const isAdmin = await isUserOrgAdmin(orgId, user.id);
if (!isAdmin) {
const orgRoles = (c.get('orgRoles') as Record<string, number> | undefined) ?? {};
const roleId = orgRoles[orgId];
if (roleId !== ROLE.ADMIN) {
return c.json(
{
success: false,
+11 -9
View File
@@ -1,12 +1,13 @@
import { Context, Next } from 'hono';
import { getUserOrgRole } from '@cio/db/queries/organization';
/**
* Middleware to check if the authenticated user is a member of the organization
* Requires authMiddleware to be applied first
* Expects orgId in the 'cio-org-id' header
* Stores orgRole and orgId in context for use in route handlers
* Middleware to check if the authenticated user is a member of the organization.
* Reads the role from the Better Auth session (populated by the customSession plugin),
* so no per-request DB query is performed.
*
* Requires authMiddleware to be applied first.
* Expects orgId in the 'cio-org-id' header.
* Stores orgRole and orgId in context for use in route handlers.
*/
export const orgMemberMiddleware = async (c: Context, next: Next) => {
try {
@@ -34,8 +35,10 @@ export const orgMemberMiddleware = async (c: Context, next: Next) => {
);
}
const roleId = await getUserOrgRole(orgId, user.id);
if (roleId === null) {
const orgRoles = (c.get('orgRoles') as Record<string, number> | undefined) ?? {};
const roleId = orgRoles[orgId];
if (roleId === undefined) {
return c.json(
{
success: false,
@@ -46,7 +49,6 @@ export const orgMemberMiddleware = async (c: Context, next: Next) => {
);
}
// Store orgRole and orgId in context for use in route handlers
c.set('userRole', roleId);
c.set('orgId', orgId);
+11 -6
View File
@@ -1,12 +1,15 @@
import { Context, Next } from 'hono';
import { ErrorCodes } from '@api/utils/errors';
import { isUserOrgTeamMember } from '@cio/db/queries/organization';
import { ROLE } from '@cio/utils/constants';
/**
* Middleware to check if the authenticated user is a team member (ADMIN or TUTOR) of the organization
* Requires authMiddleware to be applied first
* Expects orgId in the 'cio-org-id' header
* Middleware to check if the authenticated user is a team member (ADMIN or TUTOR) of the organization.
* Reads the role from the Better Auth session (populated by the customSession plugin),
* so no per-request DB query is performed.
*
* Requires authMiddleware to be applied first.
* Expects orgId in the 'cio-org-id' header.
*/
export const orgTeamMemberMiddleware = async (c: Context, next: Next) => {
try {
@@ -35,8 +38,10 @@ export const orgTeamMemberMiddleware = async (c: Context, next: Next) => {
);
}
const isTeamMember = await isUserOrgTeamMember(orgId, user.id);
if (!isTeamMember) {
const orgRoles = (c.get('orgRoles') as Record<string, number> | undefined) ?? {};
const roleId = orgRoles[orgId];
if (roleId !== ROLE.ADMIN && roleId !== ROLE.TUTOR) {
return c.json(
{
success: false,
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
import { ZAgentCreditsBody } from '@cio/utils/validation/agent';
import { addCredits, getTokenBalance } from '@api/services/agent/usage';
/**
* POST /agent/credits
* Purchase additional token credits. Org admins only.
* In v1, this directly adds credits (no payment integration).
* Payment integration will be added when billing provider is wired up.
*/
export const agentCreditsRouter = new Hono().post(
'/credits',
authMiddleware,
orgAdminMiddleware,
zValidator('json', ZAgentCreditsBody),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { amount } = c.req.valid('json');
// TODO: Integrate with payment provider before adding credits.
// For now, this endpoint adds credits directly for testing purposes.
await addCredits(orgId, amount);
const balance = await getTokenBalance(orgId);
return c.json({ success: true, data: balance });
} catch (error) {
return handleError(c, error, 'Failed to purchase credits');
}
}
);
+172
View File
@@ -0,0 +1,172 @@
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { orgMemberMiddleware } from '@api/middlewares/org-member';
import { zValidator } from '@hono/zod-validator';
import {
ZAgentHistoryQuery,
ZAgentConversationParam,
ZAgentConversationCreateBody,
ZAgentHistorySaveBody,
ZAgentHistoryDeleteParam,
ZAgentGenerateTitleParam,
ZAgentGenerateTitleBody
} from '@cio/utils/validation/agent';
import { handleError, AppError } from '@api/utils/errors';
import {
listChatConversations,
getChatConversation,
createChatConversation,
saveChatMessages,
deleteChatConversation,
updateConversationTitle
} from '@api/services/agent/chat-history';
import { generateConversationTitle } from '@api/services/agent/title-generation';
import { pickAnyConfiguredProvider } from '@cio/ai-assistant/providers';
export const agentHistoryRouter = new Hono()
/**
* GET /agent/history?courseId=...
* List all conversations for the current user in a course.
*/
.get('/history', authMiddleware, orgMemberMiddleware, zValidator('query', ZAgentHistoryQuery), async (c) => {
try {
const user = c.get('user')!;
const { courseId } = c.req.valid('query');
const conversations = await listChatConversations(courseId, user.id);
return c.json({ success: true as const, data: conversations });
} catch (error) {
return handleError(c, error, 'Failed to list conversations');
}
})
/**
* GET /agent/history/:conversationId
* Get a single conversation with its messages.
*/
.get(
'/history/:conversationId',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZAgentConversationParam),
async (c) => {
try {
const user = c.get('user')!;
const { conversationId } = c.req.valid('param');
const conversation = await getChatConversation(conversationId, user.id);
if (!conversation) {
return c.json({ success: false, error: 'Conversation not found' }, 404);
}
return c.json({ success: true as const, data: conversation });
} catch (error) {
return handleError(c, error, 'Failed to fetch conversation');
}
}
)
/**
* POST /agent/history
* Create a new conversation.
*/
.post(
'/history',
authMiddleware,
orgMemberMiddleware,
zValidator('json', ZAgentConversationCreateBody),
async (c) => {
try {
const user = c.get('user')!;
const { courseId, title } = c.req.valid('json');
const conversation = await createChatConversation(courseId, user.id, title);
return c.json({ success: true as const, data: conversation }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create conversation');
}
}
)
/**
* PUT /agent/history/:conversationId
* Save messages to a conversation.
*/
.put(
'/history/:conversationId',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZAgentConversationParam),
zValidator('json', ZAgentHistorySaveBody),
async (c) => {
try {
const user = c.get('user')!;
const { conversationId } = c.req.valid('param');
const { messages, title } = c.req.valid('json');
await saveChatMessages(conversationId, user.id, messages, title);
return c.json({ success: true as const });
} catch (error) {
return handleError(c, error, 'Failed to save messages');
}
}
)
/**
* DELETE /agent/history/:conversationId
* Delete a conversation.
*/
.delete(
'/history/:conversationId',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZAgentHistoryDeleteParam),
async (c) => {
try {
const user = c.get('user')!;
const { conversationId } = c.req.valid('param');
await deleteChatConversation(conversationId, user.id);
return c.json({ success: true as const });
} catch (error) {
return handleError(c, error, 'Failed to delete conversation');
}
}
)
/**
* POST /agent/history/:conversationId/generate-title
* Generate a short title from the first user message using a cheap model.
*/
.post(
'/history/:conversationId/generate-title',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZAgentGenerateTitleParam),
zValidator('json', ZAgentGenerateTitleBody),
async (c) => {
try {
const user = c.get('user')!;
const { conversationId } = c.req.valid('param');
const { firstMessageText } = c.req.valid('json');
const providerConfig = pickAnyConfiguredProvider();
if (!providerConfig) {
throw new AppError('AI assistant is not configured', 'AI_NOT_CONFIGURED', 503);
}
const title = await generateConversationTitle(firstMessageText, providerConfig);
await updateConversationTitle(conversationId, user.id, title);
return c.json({ success: true as const, data: { title } });
} catch (error) {
return handleError(c, error, 'Failed to generate title');
}
}
);
+6
View File
@@ -0,0 +1,6 @@
export { agentStatusRouter } from './status';
export { agentChatRouter } from './chat';
export { agentUploadRouter } from './upload';
export { agentUsageRouter } from './usage';
export { agentCreditsRouter } from './credits';
export { agentHistoryRouter } from './history';
+58
View File
@@ -0,0 +1,58 @@
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { orgMemberMiddleware } from '@api/middlewares/org-member';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
import { ZAgentStatusQuery } from '@cio/utils/validation/agent';
import { getTokenBalance } from '@api/services/agent/usage';
import { isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group';
import { AgentRole, type AgentStatus } from '@cio/ai-assistant';
import { pickAnyConfiguredProvider } from '@cio/ai-assistant/providers';
/**
* GET /agent/status
* Returns whether AI assistant is enabled, the user's role, and usage info.
* Used by the dashboard to show/hide the AI button and configure the UI.
*/
export const agentStatusRouter = new Hono().get(
'/status',
authMiddleware,
orgMemberMiddleware,
zValidator('query', ZAgentStatusQuery),
async (c) => {
try {
const user = c.get('user')!;
const orgId = c.req.header('cio-org-id')!;
const { courseId } = c.req.valid('query');
// Check if AI is configured (self-hosted check) — enabled when any provider key is set
const providerConfig = pickAnyConfiguredProvider();
if (!providerConfig) {
const status: AgentStatus = {
enabled: false,
role: AgentRole.STUDENT,
usage: { used: 0, allowance: 0, creditBalance: 0, remaining: 0 }
};
return c.json({ success: true, data: status });
}
// Detect role
const isTeamMember = await isCourseTeamMemberOrOrgAdmin(courseId, user.id);
const role = isTeamMember ? AgentRole.TEACHER : AgentRole.STUDENT;
// Get usage
const usage = await getTokenBalance(orgId);
const status: AgentStatus = {
enabled: true,
role,
usage
};
return c.json({ success: true, data: status });
} catch (error) {
return handleError(c, error, 'Failed to get agent status');
}
}
);
+91
View File
@@ -0,0 +1,91 @@
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { orgMemberMiddleware } from '@api/middlewares/org-member';
import { handleError, AppError } from '@api/utils/errors';
import { parseAndStoreDocument } from '@api/services/agent/document';
import { isOrgOnPaidPlan } from '@api/services/agent/usage';
import { isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group';
import { getChatConversation } from '@cio/db/queries/agent';
import { redis } from '@api/utils/redis/redis';
/**
* POST /agent/upload
* Upload a PDF, DOCX, or PPTX file for AI-powered course plan generation.
* EARLY_ADOPTER+ plan required. Max 5MB.
*/
export const agentUploadRouter = new Hono().post('/upload', authMiddleware, orgMemberMiddleware, async (c) => {
try {
const user = c.get('user')!;
const orgId = c.req.header('cio-org-id')!;
const courseId = c.req.query('courseId');
if (!courseId) {
throw new AppError('Course ID is required', 'COURSE_ID_REQUIRED', 400);
}
const conversationId = c.req.query('conversationId');
if (!conversationId) {
throw new AppError('Conversation ID is required', 'CONVERSATION_ID_REQUIRED', 400);
}
// Verify course team membership
const isTeamMember = await isCourseTeamMemberOrOrgAdmin(courseId, user.id);
if (!isTeamMember) {
throw new AppError('You must be a course team member to upload documents', 'NOT_COURSE_TEAM_MEMBER', 403);
}
// Verify the conversation exists and belongs to this user + course
const conversation = await getChatConversation(conversationId, user.id);
if (!conversation || conversation.courseId !== courseId) {
throw new AppError('Conversation not found', 'CONVERSATION_NOT_FOUND', 404);
}
// Plan gate — EARLY_ADOPTER+ required
const isPaid = await isOrgOnPaidPlan(orgId);
if (!isPaid) {
return c.json(
{
success: false,
error: 'document_upload_requires_upgrade',
upgradeRequired: true
},
403
);
}
// Parse multipart body
const body = await c.req.parseBody();
const file = body.file;
if (!(file instanceof File)) {
throw new AppError('File is required', 'FILE_REQUIRED', 400);
}
const result = await parseAndStoreDocument(file, orgId, user.id, courseId, conversationId, redis);
return c.json({ success: true, data: result });
} catch (error) {
if (error instanceof AppError) {
if (error.statusCode === 413) {
return c.json({ success: false, error: 'file_too_large', maxSize: 5242880 }, 413);
}
if (error.statusCode === 415) {
return c.json(
{
success: false,
error: 'unsupported_file_type',
allowed: [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.presentationml.presentation'
]
},
415
);
}
}
return handleError(c, error, 'Failed to upload document');
}
});
+21
View File
@@ -0,0 +1,21 @@
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { orgMemberMiddleware } from '@api/middlewares/org-member';
import { handleError } from '@api/utils/errors';
import { getDetailedUsage } from '@api/services/agent/usage';
/**
* GET /agent/usage
* Returns detailed usage stats for the current org:
* used, allowance, creditBalance, remaining, and daily history for the current month.
*/
export const agentUsageRouter = new Hono().get('/usage', authMiddleware, orgMemberMiddleware, async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const usage = await getDetailedUsage(orgId);
return c.json({ success: true, data: usage });
} catch (error) {
return handleError(c, error, 'Failed to fetch usage stats');
}
});
+135
View File
@@ -0,0 +1,135 @@
import {
ZCourseComplianceExtend,
ZCourseComplianceLearnerParam,
ZCourseComplianceParam,
ZCourseComplianceReset,
ZCourseComplianceWaive
} from '@cio/utils/validation/course/compliance';
import {
extendCourseCompliance,
getCourseComplianceOverview,
getLearnerComplianceHistory,
resetCourseCompliance,
waiveCourseCompliance
} from '@api/services/course/compliance';
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { courseMemberMiddleware } from '@api/middlewares/course-member';
import { courseTeamMemberMiddleware } from '@api/middlewares/course-team-member';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
export const complianceRouter = new Hono()
.get('/', authMiddleware, courseTeamMemberMiddleware, zValidator('param', ZCourseComplianceParam), async (c) => {
try {
const { courseId } = c.req.valid('param');
const data = await getCourseComplianceOverview(courseId);
return c.json(
{
success: true,
data
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to fetch course compliance overview');
}
})
.get(
'/learners/:profileId',
authMiddleware,
courseMemberMiddleware,
zValidator('param', ZCourseComplianceLearnerParam),
async (c) => {
try {
const { courseId, profileId } = c.req.valid('param');
const user = c.get('user')!;
const data = await getLearnerComplianceHistory(courseId, user.id, profileId);
return c.json(
{
success: true,
data
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to fetch learner compliance history');
}
}
)
.post(
'/reset',
authMiddleware,
courseTeamMemberMiddleware,
zValidator('param', ZCourseComplianceParam),
zValidator('json', ZCourseComplianceReset),
async (c) => {
try {
const { courseId } = c.req.valid('param');
const payload = c.req.valid('json');
const data = await resetCourseCompliance(courseId, payload);
return c.json(
{
success: true,
data
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to reset course compliance');
}
}
)
.post(
'/extend',
authMiddleware,
courseTeamMemberMiddleware,
zValidator('param', ZCourseComplianceParam),
zValidator('json', ZCourseComplianceExtend),
async (c) => {
try {
const { courseId } = c.req.valid('param');
const payload = c.req.valid('json');
const data = await extendCourseCompliance(courseId, payload);
return c.json(
{
success: true,
data
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to extend course compliance');
}
}
)
.post(
'/waive',
authMiddleware,
courseTeamMemberMiddleware,
zValidator('param', ZCourseComplianceParam),
zValidator('json', ZCourseComplianceWaive),
async (c) => {
try {
const { courseId } = c.req.valid('param');
const payload = c.req.valid('json');
const user = c.get('user')!;
const data = await waiveCourseCompliance(courseId, user.id, payload);
return c.json(
{
success: true,
data
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to waive course compliance');
}
}
);
+2
View File
@@ -34,6 +34,7 @@ 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 { complianceRouter } from '@api/routes/course/compliance';
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';
@@ -531,6 +532,7 @@ export const courseRouter = new Hono()
.route('/katex', katexRouter)
.route('/:courseId/payment-request', paymentRequestRouter)
.route('/:courseId/content', contentRouter)
.route('/:courseId/compliance', complianceRouter)
.route('/:courseId/section', sectionRouter)
.route('/:courseId/lesson', lessonRouter)
.route('/:courseId/exercise', exerciseRouter)
+3 -1
View File
@@ -11,6 +11,7 @@ import { ZTemplateById, ZTemplateByTag } from '@cio/utils/validation/mocks';
import {
createExercise,
createExerciseFromTemplate,
deleteExerciseForCourseService,
deleteExerciseService,
getExercise,
listExercises,
@@ -222,8 +223,9 @@ export const exerciseRouter = new Hono()
)
.delete('/:exerciseId', authMiddleware, courseMemberMiddleware, zValidator('param', ZExerciseGetParam), async (c) => {
try {
const courseId = c.req.param('courseId')!;
const { exerciseId } = c.req.valid('param');
const exercise = await deleteExerciseService(exerciseId);
const exercise = await deleteExerciseForCourseService(courseId, exerciseId);
return c.json({ success: true, data: exercise }, 200);
} catch (error) {
+27 -10
View File
@@ -1,17 +1,14 @@
import { Hono } from '@api/utils/hono';
import { ZDashStats } from '@cio/utils/validation/dash';
import { ZDashStats, ZLoginActivity } from '@cio/utils/validation/dash';
import { authMiddleware } from '@api/middlewares/auth';
import { getOrganisationAnalytics } from '@api/services/dash';
import { getCurrentUserLoginStreak, getOrganisationAnalytics, getStudentLoginActivity } from '@api/services/dash';
import { handleError } from '@api/utils/errors';
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { orgMemberMiddleware } from '@api/middlewares/org-member';
import { zValidator } from '@hono/zod-validator';
export const dashAnalyticsRouter = new Hono().get(
'/stats',
authMiddleware,
orgMemberMiddleware,
zValidator('query', ZDashStats),
async (c) => {
export const dashAnalyticsRouter = new Hono()
.get('/stats', authMiddleware, orgMemberMiddleware, zValidator('query', ZDashStats), async (c) => {
try {
const { orgId, siteName } = c.req.valid('query');
@@ -21,5 +18,25 @@ export const dashAnalyticsRouter = new Hono().get(
} catch (error) {
return handleError(c, error, 'Failed to load organisation analytics');
}
}
);
})
.get('/login-activity', authMiddleware, orgAdminMiddleware, zValidator('query', ZLoginActivity), async (c) => {
try {
const { orgId, days } = c.req.valid('query');
const result = await getStudentLoginActivity(orgId!, days);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to load login activity');
}
})
.get('/login-streak', authMiddleware, async (c) => {
try {
const user = c.get('user')!;
const result = await getCurrentUserLoginStreak(user.id);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to load login streak');
}
});
@@ -0,0 +1,37 @@
import { runComplianceExpiryCheck, runComplianceReminderScan } from '@api/services/course/compliance';
import { Hono } from '@api/utils/hono';
import { apiKeyMiddleware } from '@api/middlewares/api-key';
import { handleError } from '@api/utils/errors';
export const internalComplianceRouter = new Hono()
.post('/check-expiry', apiKeyMiddleware, async (c) => {
try {
const data = await runComplianceExpiryCheck();
return c.json(
{
success: true,
data
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to run compliance expiry check');
}
})
.post('/send-reminders', apiKeyMiddleware, async (c) => {
try {
const data = await runComplianceReminderScan();
return c.json(
{
success: true,
data
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to run compliance reminder scan');
}
});
@@ -4,3 +4,4 @@ export * from './tags';
export * from './token-auth';
export * from './course-import';
export * from './automation';
export * from './widgets';
@@ -61,7 +61,9 @@ import { orgMemberMiddleware } from '@api/middlewares/org-member';
import { orgMemberOrAutomationKeyMiddleware } from '@api/middlewares/org-member-or-automation-key';
import { orgTeamMemberMiddleware } from '@api/middlewares/org-team-member';
import { quizRouter } from '@api/routes/organization/quiz';
import { searchRouter } from '@api/routes/organization/search';
import { tagsRouter } from '@api/routes/organization/tags';
import { widgetsRouter } from '@api/routes/organization/widgets';
import { zValidator } from '@hono/zod-validator';
export const organizationRouter = new Hono()
@@ -664,6 +666,8 @@ export const organizationRouter = new Hono()
)
.route('/automation', automationRouter)
.route('/course-import', courseImportRouter)
.route('/search', searchRouter)
.route('/tags', tagsRouter)
.route('/widgets', widgetsRouter)
.route('/assets', assetsRouter)
.route('/:orgId/quiz', quizRouter);
@@ -0,0 +1,34 @@
import { ZSearchOrganization } from '@cio/utils/validation/organization';
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { handleError } from '@api/utils/errors';
import { orgMemberMiddleware } from '@api/middlewares/org-member';
import { orgTeamMemberMiddleware } from '@api/middlewares/org-team-member';
import { searchLmsOrganization, searchOrganization } from '@api/services/organization/search';
import { zValidator } from '@hono/zod-validator';
export const searchRouter = new Hono()
.get('/', authMiddleware, orgTeamMemberMiddleware, zValidator('query', ZSearchOrganization), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { q, limit } = c.req.valid('query');
const results = await searchOrganization(orgId, q, limit);
return c.json({ success: true, data: results });
} catch (error) {
return handleError(c, error, 'Failed to search organization');
}
})
.get('/lms', authMiddleware, orgMemberMiddleware, zValidator('query', ZSearchOrganization), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const user = c.get('user')!;
const { q, limit } = c.req.valid('query');
const results = await searchLmsOrganization(orgId, user.id, q, limit);
return c.json({ success: true, data: results });
} catch (error) {
return handleError(c, error, 'Failed to search LMS');
}
});
+140
View File
@@ -0,0 +1,140 @@
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { handleError } from '@api/utils/errors';
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { orgTeamMemberMiddleware } from '@api/middlewares/org-team-member';
import {
ZCreateWidget,
ZPreviewWidget,
ZRollbackWidget,
ZUpdateWidget,
ZWidgetIdParams
} from '@cio/utils/validation/widget';
import {
createOrganizationWidget,
deleteOrganizationWidget,
getOrganizationWidgetDetail,
listOrganizationWidgets,
previewOrganizationWidget,
publishOrganizationWidget,
rollbackOrganizationWidget,
updateOrganizationWidget
} from '@api/services/widget';
import { zValidator } from '@hono/zod-validator';
export const widgetsRouter = new Hono()
.get('/', authMiddleware, orgTeamMemberMiddleware, async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const widgets = await listOrganizationWidgets(orgId);
return c.json({ success: true, data: widgets });
} catch (error) {
return handleError(c, error, 'Failed to fetch widgets');
}
})
.post('/', authMiddleware, orgTeamMemberMiddleware, zValidator('json', ZCreateWidget), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const user = c.get('user')!;
const data = c.req.valid('json');
const widget = await createOrganizationWidget(orgId, user.id, data);
return c.json({ success: true, data: widget }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create widget');
}
})
.get('/:widgetId', authMiddleware, orgTeamMemberMiddleware, zValidator('param', ZWidgetIdParams), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { widgetId } = c.req.valid('param');
const widgetDetail = await getOrganizationWidgetDetail(orgId, widgetId);
return c.json({ success: true, data: widgetDetail });
} catch (error) {
return handleError(c, error, 'Failed to fetch widget');
}
})
.put(
'/:widgetId',
authMiddleware,
orgTeamMemberMiddleware,
zValidator('param', ZWidgetIdParams),
zValidator('json', ZUpdateWidget),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const user = c.get('user')!;
const { widgetId } = c.req.valid('param');
const data = c.req.valid('json');
const widget = await updateOrganizationWidget(orgId, widgetId, user.id, data);
return c.json({ success: true, data: widget });
} catch (error) {
return handleError(c, error, 'Failed to update widget');
}
}
)
.delete('/:widgetId', authMiddleware, orgAdminMiddleware, zValidator('param', ZWidgetIdParams), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const user = c.get('user')!;
const { widgetId } = c.req.valid('param');
const widget = await deleteOrganizationWidget(orgId, widgetId, user.id);
return c.json({ success: true, data: widget });
} catch (error) {
return handleError(c, error, 'Failed to delete widget');
}
})
.post(
'/:widgetId/preview',
authMiddleware,
orgTeamMemberMiddleware,
zValidator('param', ZWidgetIdParams),
zValidator('json', ZPreviewWidget),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { widgetId } = c.req.valid('param');
const data = c.req.valid('json');
const payload = await previewOrganizationWidget(orgId, widgetId, data);
return c.json({ success: true, data: payload });
} catch (error) {
return handleError(c, error, 'Failed to build widget preview');
}
}
)
.post('/:widgetId/publish', authMiddleware, orgAdminMiddleware, zValidator('param', ZWidgetIdParams), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const user = c.get('user')!;
const { widgetId } = c.req.valid('param');
const result = await publishOrganizationWidget(orgId, widgetId, user.id);
return c.json({ success: true, data: result });
} catch (error) {
return handleError(c, error, 'Failed to publish widget');
}
})
.post(
'/:widgetId/rollback',
authMiddleware,
orgAdminMiddleware,
zValidator('param', ZWidgetIdParams),
zValidator('json', ZRollbackWidget),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const user = c.get('user')!;
const { widgetId } = c.req.valid('param');
const data = c.req.valid('json');
const result = await rollbackOrganizationWidget(orgId, widgetId, user.id, data);
return c.json({ success: true, data: result });
} catch (error) {
return handleError(c, error, 'Failed to rollback widget');
}
}
);
+1
View File
@@ -0,0 +1 @@
export * from './widgets';
+39
View File
@@ -0,0 +1,39 @@
import { Hono } from '@api/utils/hono';
import { handleError } from '@api/utils/errors';
import { getPublishedWidgetPayload } from '@api/services/widget';
import { ZWidgetPublicKeyParams } from '@cio/utils/validation/widget';
import { zValidator } from '@hono/zod-validator';
import { createRateLimiter } from '@api/middlewares/rate-limiter';
import { DEFAULT_WINDOW_MS } from '@api/constants/rate-limiter';
const widgetPayloadIpLimiter = createRateLimiter({
maxRequests: 100,
windowMs: DEFAULT_WINDOW_MS,
keyGenerator: (c) =>
`widget_payload_ip:${c.req.header('cf-connecting-ip') || c.req.header('x-forwarded-for') || 'unknown'}`
});
const widgetPayloadKeyLimiter = createRateLimiter({
maxRequests: 1000,
windowMs: DEFAULT_WINDOW_MS,
keyGenerator: (c) => `widget_payload_key:${c.req.param('publicKey')}`
});
export const publicWidgetsRouter = new Hono().get(
'/:publicKey/payload',
zValidator('param', ZWidgetPublicKeyParams),
widgetPayloadIpLimiter,
widgetPayloadKeyLimiter,
async (c) => {
try {
const { publicKey } = c.req.valid('param');
const payload = await getPublishedWidgetPayload(publicKey);
c.header('Cache-Control', 'public, max-age=60, stale-while-revalidate=300');
return c.json({ success: true, data: payload });
} catch (error) {
return handleError(c, error, 'Failed to fetch widget payload');
}
}
);
@@ -0,0 +1,24 @@
import {
listChatConversations,
getChatConversation,
createChatConversation,
saveChatMessages,
deleteChatConversation,
updateConversationTitle,
getChatHistory,
saveChatHistory,
deleteChatHistory
} from '@cio/db/queries/agent';
export {
listChatConversations,
getChatConversation,
createChatConversation,
saveChatMessages,
deleteChatConversation,
updateConversationTitle,
// Legacy compat
getChatHistory as fetchChatHistory,
saveChatHistory as persistChatHistory,
deleteChatHistory as clearChatHistory
};
@@ -0,0 +1,27 @@
/**
* Trims conversation message history to stay within token budget.
*
* Strategy:
* - Always keep the first message (often contains the initial request/document context)
* - Always keep the last N messages (recent context is most relevant)
* - Drop middle messages when the array is too long
*
* This is a message-count heuristic, not a token counter. Each message averages
* ~200-500 tokens, so capping at ~40 messages keeps us under ~20K tokens of
* history, leaving room for the system prompt + document text + LLM response.
*/
const MAX_MESSAGES = 40;
const KEEP_RECENT = 30;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function trimMessageHistory(messages: any[]): any[] {
if (messages.length <= MAX_MESSAGES) {
return messages;
}
const firstMessage = messages[0];
const recentMessages = messages.slice(-KEEP_RECENT);
return [firstMessage, ...recentMessages];
}
+227
View File
@@ -0,0 +1,227 @@
import { nanoid } from 'nanoid';
import { AppError } from '@api/utils/errors';
import {
MAX_DOCUMENT_TEXT_LENGTH,
MAX_AGENT_DOCUMENT_SIZE,
DOCUMENT_REDIS_TTL,
SUPPORTED_DOCUMENT_TYPES
} from '@cio/ai-assistant';
import type { DocumentUploadResult } from '@cio/ai-assistant';
import { agentDocumentKey } from '@api/utils/redis/key-generators';
import { trackAgentEvent, AgentEvent } from '@api/utils/tinybird';
import type { RedisClient } from '@api/utils/redis/redis';
import { createChatDocument, getChatDocument } from '@cio/db/queries/agent';
import { generateFileKey } from '@api/utils/upload';
import { uploadToS3 } from '@api/utils/s3';
import { getStorageConfig } from '@api/config/storage';
import { createAssetFromUploadService } from '@api/services/assets/assets';
/**
* Parse an uploaded document, store extracted text in Redis (hot cache) and Postgres
* (durable, scoped to a conversation). Supports PDF, DOCX, and PPTX files.
*/
export async function parseAndStoreDocument(
file: File,
orgId: string,
userId: string,
courseId: string,
conversationId: string,
redis: RedisClient
): Promise<DocumentUploadResult> {
const mimeType = file.type;
if (!SUPPORTED_DOCUMENT_TYPES.includes(mimeType as (typeof SUPPORTED_DOCUMENT_TYPES)[number])) {
throw new AppError('Unsupported file type. Allowed: PDF, DOCX, PPTX', 'UNSUPPORTED_FILE_TYPE', 415);
}
if (file.size > MAX_AGENT_DOCUMENT_SIZE) {
throw new AppError(
`File too large. Maximum size is ${MAX_AGENT_DOCUMENT_SIZE / (1024 * 1024)}MB`,
'FILE_TOO_LARGE',
413
);
}
const buffer = Buffer.from(await file.arrayBuffer());
let extractedText: string;
let pageCount: number | null = null;
switch (mimeType) {
case 'application/pdf':
({ text: extractedText, pageCount } = await extractPdfText(buffer));
break;
case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
extractedText = await extractDocxText(buffer);
break;
case 'application/vnd.openxmlformats-officedocument.presentationml.presentation':
({ text: extractedText, pageCount } = await extractPptxText(buffer));
break;
default:
throw new AppError('Unsupported file type', 'UNSUPPORTED_FILE_TYPE', 415);
}
const truncated = extractedText.length > MAX_DOCUMENT_TEXT_LENGTH;
if (truncated) {
extractedText = extractedText.slice(0, MAX_DOCUMENT_TEXT_LENGTH);
}
const wordCount = extractedText.split(/\s+/).filter(Boolean).length;
const textPreview = extractedText.slice(0, 200);
const documentId = nanoid();
// Persist the original file to S3 and register it as an asset so it shows
// up in the org's asset manager — same pipeline lessons use.
const storageKey = generateFileKey(file.name);
const storageConfig = getStorageConfig();
const uploadResult = await uploadToS3({
Bucket: storageConfig.bucketDocuments,
Key: storageKey,
Body: buffer,
ContentType: mimeType
});
if (!uploadResult.success) {
throw new AppError(
`Failed to upload document to storage: ${uploadResult.error ?? 'unknown error'}`,
'DOCUMENT_STORAGE_FAILED',
500
);
}
const asset = await createAssetFromUploadService(orgId, userId, {
kind: 'document',
provider: 'upload',
storageProvider: 's3',
storageKey,
byteSize: file.size,
mimeType,
title: file.name,
isExternal: false,
metadata: { source: 'ai_chat', conversationId }
});
await redis.set(
agentDocumentKey(documentId),
JSON.stringify({
text: extractedText,
fileName: file.name,
mimeType,
uploadedAt: new Date().toISOString()
}),
{ EX: DOCUMENT_REDIS_TTL }
);
await createChatDocument({
id: documentId,
conversationId,
courseId,
userId,
assetId: asset.id,
fileName: file.name,
mimeType,
text: extractedText,
wordCount,
pageCount
});
trackAgentEvent(AgentEvent.DOCUMENT_UPLOADED, {
orgId,
userId,
courseId,
mimeType,
fileSize: file.size,
wordCount,
truncated
});
return {
documentId,
fileName: file.name,
mimeType,
pageCount,
wordCount,
textPreview,
truncated
};
}
/**
* Retrieve stored document text. Tries the Redis hot cache first; on miss
* falls back to Postgres and rehydrates Redis for next time.
*/
export async function getDocumentText(documentId: string, redis: RedisClient): Promise<string | null> {
const raw = await redis.get(agentDocumentKey(documentId));
if (raw) {
const parsed = JSON.parse(raw) as { text: string };
return parsed.text;
}
const record = await getChatDocument(documentId);
if (!record) return null;
await redis.set(
agentDocumentKey(documentId),
JSON.stringify({
text: record.text,
fileName: record.fileName,
mimeType: record.mimeType,
uploadedAt: record.createdAt
}),
{ EX: DOCUMENT_REDIS_TTL }
);
return record.text;
}
// ─── Extraction Helpers ──────────────────────────────────────────────────────
async function extractPdfText(buffer: Buffer): Promise<{ text: string; pageCount: number }> {
const pdfParse = (await import('pdf-parse')).default;
const result = await pdfParse(buffer);
return { text: result.text, pageCount: result.numpages };
}
async function extractDocxText(buffer: Buffer): Promise<string> {
const mammoth = await import('mammoth');
const result = await mammoth.extractRawText({ buffer });
return result.value;
}
async function extractPptxText(buffer: Buffer): Promise<{ text: string; pageCount: number }> {
// pptx-parser may not be available yet — use a simpler approach
// For now, try to use officegen or a basic XML extraction
try {
const JSZip = (await import('jszip')).default;
const zip = await JSZip.loadAsync(buffer);
const slideTexts: string[] = [];
const slideFiles = Object.keys(zip.files)
.filter((name) => name.match(/^ppt\/slides\/slide\d+\.xml$/))
.sort();
for (const slideFile of slideFiles) {
const content = await zip.files[slideFile].async('text');
// Extract text from XML by stripping tags
const textContent = content
.replace(/<a:t[^>]*>/g, '')
.replace(/<\/a:t>/g, '\n')
.replace(/<[^>]+>/g, '')
.replace(/\n{3,}/g, '\n\n')
.trim();
if (textContent) {
slideTexts.push(textContent);
}
}
return { text: slideTexts.join('\n\n---\n\n'), pageCount: slideFiles.length };
} catch {
throw new AppError('Failed to parse PPTX file', 'PPTX_PARSE_ERROR', 422);
}
}
@@ -0,0 +1,45 @@
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function stripLeadingLessonTitle(content: string, lessonTitle: string): string {
const normalizedTitle = lessonTitle.trim();
if (!normalizedTitle) {
return content;
}
const escapedTitle = escapeRegExp(normalizedTitle);
const patterns = [
new RegExp(`^\\s*<h[1-6][^>]*>\\s*${escapedTitle}\\s*</h[1-6]>\\s*`, 'i'),
new RegExp(
`^\\s*<p[^>]*>\\s*(?:<strong[^>]*>|<b[^>]*>)?\\s*${escapedTitle}\\s*(?:</strong>|</b>)?\\s*</p>\\s*`,
'i'
)
];
let nextContent = content;
for (const pattern of patterns) {
nextContent = nextContent.replace(pattern, '');
}
return nextContent;
}
function normalizeHeadingLevels(content: string): string {
return content
.replace(/<h1(\s|>)/gi, '<h3$1')
.replace(/<\/h1>/gi, '</h3>')
.replace(/<h2(\s|>)/gi, '<h3$1')
.replace(/<\/h2>/gi, '</h3>');
}
export function normalizeAgentLessonContent(content: string, lessonTitle: string): string {
let normalizedContent = content.trim();
normalizedContent = stripLeadingLessonTitle(normalizedContent, lessonTitle);
normalizedContent = normalizeHeadingLevels(normalizedContent);
return normalizedContent.trim();
}
@@ -0,0 +1,110 @@
import { AppError } from '@api/utils/errors';
export type CurrentQuestionForPatch = {
id: number | string;
title: string;
questionTypeId: number;
points: number;
order: number;
settings?: Record<string, unknown>;
options: Array<{
id: number | string;
label: string | null;
isCorrect: boolean;
settings?: Record<string, unknown>;
}>;
};
export type QuestionPatch = {
id: number;
question?: string;
questionTypeId?: number;
points?: number;
order?: number;
settings?: Record<string, unknown>;
options?: Array<{
id?: number;
label?: string;
isCorrect?: boolean;
settings?: Record<string, unknown>;
}>;
};
export type MergedOption = {
id?: number;
label: string;
isCorrect: boolean;
settings?: Record<string, unknown>;
};
export type MergedQuestion = {
id: number;
question: string;
questionTypeId: number;
points: number;
order: number;
settings?: Record<string, unknown>;
options: MergedOption[];
};
export function buildUpdatedQuestions(
currentQuestions: CurrentQuestionForPatch[],
patches: QuestionPatch[],
exerciseId: string
): MergedQuestion[] {
const byId = new Map(currentQuestions.map((q) => [Number(q.id), q] as const));
return patches.map((patch) => {
const current = byId.get(patch.id);
if (!current) {
throw new AppError(
`Question ${patch.id} does not belong to exercise ${exerciseId}`,
'RESOURCE_NOT_IN_COURSE',
404
);
}
const currentOptions: MergedOption[] = (current.options || []).map((o) => ({
id: Number(o.id),
label: o.label || '',
isCorrect: o.isCorrect,
settings: o.settings ?? undefined
}));
const mergedSettings =
patch.settings === undefined ? current.settings : { ...(current.settings ?? {}), ...patch.settings };
let mergedOptions: MergedOption[];
if (patch.options === undefined) {
mergedOptions = currentOptions;
} else {
mergedOptions = patch.options.map((opt) => {
if (opt.id != null) {
const existingOpt = currentOptions.find((co) => co.id === opt.id);
return {
id: opt.id,
label: opt.label ?? existingOpt?.label ?? '',
isCorrect: opt.isCorrect ?? existingOpt?.isCorrect ?? false,
settings: opt.settings ?? existingOpt?.settings
};
}
return {
label: opt.label ?? '',
isCorrect: opt.isCorrect ?? false,
settings: opt.settings
};
});
}
return {
id: patch.id,
question: patch.question ?? current.title ?? '',
questionTypeId: patch.questionTypeId ?? current.questionTypeId,
points: patch.points ?? current.points,
order: patch.order ?? current.order,
settings: mergedSettings,
options: mergedOptions
};
});
}
@@ -0,0 +1,56 @@
import { generateText } from 'ai';
import { AIProvider, type AIProviderConfig } from '@cio/ai-assistant';
import { createOpenAI } from '@ai-sdk/openai';
import { createAnthropic } from '@ai-sdk/anthropic';
import { createGoogleGenerativeAI } from '@ai-sdk/google';
/**
* Cheap models used specifically for title generation to minimize token cost.
*/
const TITLE_MODELS: Record<AIProvider, string> = {
[AIProvider.OPENAI]: 'gpt-4o-mini',
[AIProvider.ANTHROPIC]: 'claude-haiku-4-20250414',
[AIProvider.GOOGLE]: 'gemini-2.5-flash'
};
function createTitleModel(config: AIProviderConfig) {
const modelName = TITLE_MODELS[config.provider];
switch (config.provider) {
case AIProvider.OPENAI: {
const openai = createOpenAI({ apiKey: config.apiKey });
return openai(modelName);
}
case AIProvider.ANTHROPIC: {
const anthropic = createAnthropic({ apiKey: config.apiKey });
return anthropic(modelName);
}
case AIProvider.GOOGLE: {
const google = createGoogleGenerativeAI({ apiKey: config.apiKey });
return google(modelName);
}
default:
throw new Error(`Unsupported AI provider: ${config.provider}`);
}
}
/**
* Generates a short conversational title from the user's first message.
* Uses the cheapest available model to minimize token cost.
*/
export async function generateConversationTitle(
firstMessageText: string,
providerConfig: AIProviderConfig
): Promise<string> {
const model = createTitleModel(providerConfig);
const { text } = await generateText({
model,
system:
"Generate a short title (max 6 words) for a chat conversation based on the user's first message. Return ONLY the title, no quotes, no punctuation at the end. Be concise and descriptive.",
prompt: firstMessageText,
maxOutputTokens: 30
});
return text.trim().slice(0, 80);
}
+209
View File
@@ -0,0 +1,209 @@
import { db } from '@cio/db';
import * as schema from '@cio/db/schema';
import { eq, and, gte, sql } from 'drizzle-orm';
import { getActiveOrganizationPlan } from '@cio/db/queries/organization';
import { AppError, ErrorCodes } from '@api/utils/errors';
import type { TokenBalance, TokenUsage } from '@cio/ai-assistant';
// ─── Plan-Based Token Allowances ─────────────────────────────────────────────
const PLAN_TOKEN_ALLOWANCES: Record<string, number> = {
BASIC: 50_000,
EARLY_ADOPTER: 500_000,
ENTERPRISE: 2_000_000
};
/**
* Get the monthly token allowance for an org based on its active plan.
* Also checks if the plan payload has a custom `aiTokenAllowance` override.
*/
async function getPlanAllowance(orgId: string): Promise<{ planName: string; allowance: number }> {
const activePlan = await getActiveOrganizationPlan(orgId);
if (!activePlan || !activePlan.planName) {
return { planName: 'BASIC', allowance: PLAN_TOKEN_ALLOWANCES.BASIC };
}
const planName = activePlan.planName;
const payload = activePlan.payload as Record<string, unknown> | null;
const customAllowance = payload?.aiTokenAllowance as number | undefined;
const allowance = customAllowance ?? PLAN_TOKEN_ALLOWANCES[planName] ?? 0;
return { planName, allowance };
}
/**
* Get the total tokens used by an org in the current month.
*/
async function getMonthlyUsage(orgId: string): Promise<number> {
const startOfMonth = new Date();
startOfMonth.setDate(1);
startOfMonth.setHours(0, 0, 0, 0);
const [result] = await db
.select({
totalTokens: sql<number>`COALESCE(SUM(${schema.aiTokenUsage.promptTokens} + ${schema.aiTokenUsage.completionTokens}), 0)`
})
.from(schema.aiTokenUsage)
.where(and(eq(schema.aiTokenUsage.orgId, orgId), gte(schema.aiTokenUsage.createdAt, startOfMonth.toISOString())));
return Number(result?.totalTokens ?? 0);
}
/**
* Get the credit balance for an org.
*/
async function getCreditBalance(orgId: string): Promise<number> {
const [result] = await db
.select({ balance: schema.aiCreditBalance.balance })
.from(schema.aiCreditBalance)
.where(eq(schema.aiCreditBalance.orgId, orgId))
.limit(1);
return result?.balance ?? 0;
}
/**
* Get the full token balance for an org: used, allowance, credits, remaining.
*/
export async function getTokenBalance(orgId: string): Promise<TokenBalance> {
const [{ planName, allowance }, monthlyUsage, creditBalance] = await Promise.all([
getPlanAllowance(orgId),
getMonthlyUsage(orgId),
getCreditBalance(orgId)
]);
const remainingAllowance = Math.max(0, allowance - monthlyUsage);
const remaining = remainingAllowance + creditBalance;
return {
used: monthlyUsage,
allowance,
creditBalance,
remaining
};
}
/**
* Check if the org has sufficient tokens. Throws 402 if exhausted.
*/
export async function enforceTokenBalance(orgId: string): Promise<TokenBalance> {
const balance = await getTokenBalance(orgId);
if (balance.remaining <= 0) {
throw new AppError('Token limit reached', 'TOKEN_LIMIT_REACHED', 402);
}
return balance;
}
/**
* Record token usage after an LLM call.
*/
export async function recordTokenUsage(
orgId: string,
userId: string,
courseId: string,
usage: TokenUsage,
model: string
): Promise<void> {
await db.insert(schema.aiTokenUsage).values({
orgId,
userId,
courseId,
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
model
});
}
/**
* Check if an org's plan supports a specific feature (e.g., document upload).
* Returns the plan name so callers can decide what to gate.
*/
export async function getOrgPlanName(orgId: string): Promise<string> {
const { planName } = await getPlanAllowance(orgId);
return planName;
}
/**
* Check if an org is on a paid plan (EARLY_ADOPTER or above).
*/
export async function isOrgOnPaidPlan(orgId: string): Promise<boolean> {
const planName = await getOrgPlanName(orgId);
return planName !== 'BASIC';
}
// ─── Usage History ───────────────────────────────────────────────────────────
export interface DailyUsage {
date: string;
tokens: number;
}
/**
* Get daily token usage for the current month, grouped by day.
*/
export async function getUsageHistory(orgId: string): Promise<DailyUsage[]> {
const startOfMonth = new Date();
startOfMonth.setDate(1);
startOfMonth.setHours(0, 0, 0, 0);
const rows = await db
.select({
date: sql<string>`DATE(${schema.aiTokenUsage.createdAt})`,
tokens: sql<number>`SUM(${schema.aiTokenUsage.promptTokens} + ${schema.aiTokenUsage.completionTokens})`
})
.from(schema.aiTokenUsage)
.where(and(eq(schema.aiTokenUsage.orgId, orgId), gte(schema.aiTokenUsage.createdAt, startOfMonth.toISOString())))
.groupBy(sql`DATE(${schema.aiTokenUsage.createdAt})`)
.orderBy(sql`DATE(${schema.aiTokenUsage.createdAt})`);
return rows.map((row) => ({
date: String(row.date),
tokens: Number(row.tokens)
}));
}
/**
* Get full detailed usage stats for the usage endpoint.
*/
export async function getDetailedUsage(orgId: string) {
const [balance, history] = await Promise.all([getTokenBalance(orgId), getUsageHistory(orgId)]);
return { ...balance, history };
}
// ─── Credit Purchases ────────────────────────────────────────────────────────
/**
* Add credits to an org's balance.
* Creates the balance row if it doesn't exist (upsert).
*/
export async function addCredits(orgId: string, amount: number): Promise<number> {
if (amount <= 0) {
throw new AppError('Credit amount must be positive', 'INVALID_CREDIT_AMOUNT', 400);
}
const existing = await db
.select({ id: schema.aiCreditBalance.id, balance: schema.aiCreditBalance.balance })
.from(schema.aiCreditBalance)
.where(eq(schema.aiCreditBalance.orgId, orgId))
.limit(1);
if (existing.length > 0) {
const newBalance = existing[0].balance + amount;
await db
.update(schema.aiCreditBalance)
.set({ balance: newBalance, updatedAt: new Date().toISOString() })
.where(eq(schema.aiCreditBalance.orgId, orgId));
return newBalance;
}
await db.insert(schema.aiCreditBalance).values({ orgId, balance: amount });
return amount;
}
@@ -499,7 +499,8 @@ async function buildCourseStructureSnapshot(orgId: string, courseId: string): Pr
description: course.description,
type: course.type,
locale: fallbackLocale,
metadata: course.metadata
metadata: course.metadata,
compliance: course.compliance ?? undefined
},
tags: tags.map((tag) => tag.name),
sections: normalizedSections,
@@ -721,6 +722,7 @@ export async function publishCourseImportDraftService(
const courseDescription = overrides.description ?? draft.course.description;
const courseType = overrides.type ?? draft.course.type;
const courseMetadata = overrides.metadata ?? draft.course.metadata;
const courseCompliance = overrides.compliance ?? draft.course.compliance;
const courseUpdateFields: Partial<TCourse> = {
title: courseTitle,
description: courseDescription,
@@ -729,6 +731,9 @@ export async function publishCourseImportDraftService(
if (courseMetadata) {
courseUpdateFields.metadata = courseMetadata;
}
if (courseCompliance) {
courseUpdateFields.compliance = courseCompliance;
}
const sectionExternalIds = new Set(draft.sections.map((section) => section.externalId));
draft.lessons.forEach((lesson) => {
@@ -754,7 +759,8 @@ export async function publishCourseImportDraftService(
title: courseTitle,
description: courseDescription,
type: courseType,
organizationId: orgId
organizationId: orgId,
compliance: courseCompliance
});
courseId = courseResult.course.id;
@@ -876,6 +882,7 @@ export async function publishCourseImportDraftToExistingCourseService(
const courseDescription = overrides.description ?? draft.course.description;
const courseType = overrides.type ?? draft.course.type;
const courseMetadata = overrides.metadata ?? draft.course.metadata;
const courseCompliance = overrides.compliance ?? draft.course.compliance;
const preferredLocale = draft.course.locale as TLocale;
const bannerImageUrl = await resolvePublishBannerImage(courseTitle, overrides);
@@ -883,7 +890,8 @@ export async function publishCourseImportDraftToExistingCourseService(
title: courseTitle,
description: courseDescription,
type: courseType,
metadata: courseMetadata
metadata: courseMetadata,
compliance: courseCompliance
});
await maybeApplyCourseBannerImage(course.id, bannerImageUrl);
+2 -1
View File
@@ -152,6 +152,7 @@ async function buildCertificationEvaluation(
): Promise<CourseCompletionEvaluation> {
const progress = progressArg ?? (await getCourseProgressQuery(courseId, profileId));
const certificate = courseRow.certificate ?? {};
const complianceMinScore = courseRow.type === 'COMPLIANCE' ? (courseRow.compliance?.passingScore ?? null) : null;
const threshold = certificate.threshold ?? 100;
const progressPercent = calcCourseProgressPercent({
@@ -184,7 +185,7 @@ async function buildCertificationEvaluation(
const finalEval = await evaluateFinalExerciseRule({
courseId,
requiredExerciseId: certificate.requiredExerciseId,
minScorePercent: certificate.exerciseMinScorePercent,
minScorePercent: certificate.exerciseMinScorePercent ?? complianceMinScore,
groupMemberId: progress.groupMemberId
});
+714
View File
@@ -0,0 +1,714 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import {
createCourseCertificateIssue,
createCourseCompletionNotificationEvent,
createCourseCompletionRecord,
getCourseComplianceHistoryRows,
getCourseCurrentComplianceRows,
getLatestComplianceRecordsByProfiles,
getStudentCourseMembersForCompliance,
listComplianceRecordsReadyForExpiry,
listLatestComplianceRecordsForReminderScan,
updateCourseCertificateIssueStatusByRecordId,
updateCourseCompletionRecord
} from '@cio/db/queries/course/compliance';
import { db } from '@cio/db/drizzle';
import { getCourseById } from '@cio/db/queries/course';
import { ROLE } from '@cio/utils/constants';
import type {
TCourseComplianceExtend,
TCourseComplianceReset,
TCourseComplianceWaive
} from '@cio/utils/validation/course/compliance';
import type { TCourseCompletionRecord } from '@cio/db/types';
import {
getExerciseTitleAndMaxPoints,
getStudentSubmissionsForExercise
} from '@cio/db/queries/course/certification-exercise';
import { getProfileByGroupMemberId } from '@cio/db/queries/course/people';
import { getUserCourseRole } from '@cio/db/queries/group/group';
import { evaluateCourseCertification } from './completion';
type ComplianceStatus =
| 'not_started'
| 'in_progress'
| 'compliant'
| 'expiring_soon'
| 'in_grace_period'
| 'non_compliant'
| 'waived';
type ComplianceSettings = NonNullable<Awaited<ReturnType<typeof getComplianceCourseOrThrow>>['compliance']>;
function ensureFutureOrPresentIsoDate(dateIso: string, field: string) {
const date = new Date(dateIso);
if (Number.isNaN(date.getTime())) {
throw new AppError('Invalid date provided', ErrorCodes.VALIDATION_ERROR, 400, field);
}
}
function getReminderEventType(daysUntilDue: number) {
return `reminder_${daysUntilDue}d`;
}
function addMonths(isoDate: string, months: number) {
const date = new Date(isoDate);
date.setUTCMonth(date.getUTCMonth() + months);
return date.toISOString();
}
function getWholeDaysUntil(startDate: Date, endDate: Date) {
const millisecondsPerDay = 24 * 60 * 60 * 1000;
return Math.ceil((endDate.getTime() - startDate.getTime()) / millisecondsPerDay);
}
function getStatusCounts(records: Array<{ record: TCourseCompletionRecord | null }>) {
const counts: Record<ComplianceStatus | 'no_record', number> = {
no_record: 0,
not_started: 0,
in_progress: 0,
compliant: 0,
expiring_soon: 0,
in_grace_period: 0,
non_compliant: 0,
waived: 0
};
for (const row of records) {
if (!row.record) {
counts.no_record += 1;
continue;
}
const status = row.record.status as ComplianceStatus;
counts[status] = (counts[status] ?? 0) + 1;
}
return counts;
}
async function getComplianceCourseOrThrow(courseId: string) {
const [course] = await getCourseById(courseId);
if (!course) {
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
}
if (course.type !== 'COMPLIANCE' || !course.compliance) {
throw new AppError('Course is not a compliance course', ErrorCodes.VALIDATION_ERROR, 400);
}
return course;
}
async function assertCourseComplianceHistoryAccess(
courseId: string,
currentProfileId: string,
targetProfileId: string
) {
if (currentProfileId === targetProfileId) {
return;
}
const roleId = await getUserCourseRole(courseId, currentProfileId);
if (roleId !== ROLE.ADMIN && roleId !== ROLE.TUTOR) {
throw new AppError('You do not have permission to view this learner', ErrorCodes.VALIDATION_ERROR, 403);
}
}
async function getTargetStudentMembers(courseId: string, profileIds?: string[]) {
const students = await getStudentCourseMembersForCompliance(courseId, profileIds);
if (profileIds && students.length !== profileIds.length) {
throw new AppError(
'One or more learners were not found in this course',
ErrorCodes.VALIDATION_ERROR,
400,
'profileIds'
);
}
return students;
}
function resolveComplianceInitialDueDate(course: Awaited<ReturnType<typeof getComplianceCourseOrThrow>>) {
const dueDate = course.certificate?.deadline ?? null;
if (!dueDate) {
throw new AppError(
'Compliance courses require a certificate deadline before learners can be enrolled',
ErrorCodes.VALIDATION_ERROR,
400,
'certificate.deadline'
);
}
const parsedDueDate = new Date(dueDate);
if (Number.isNaN(parsedDueDate.getTime())) {
throw new AppError(
'Compliance course deadline is invalid',
ErrorCodes.VALIDATION_ERROR,
400,
'certificate.deadline'
);
}
return dueDate;
}
function getCurrentIncompleteRecord(record: TCourseCompletionRecord | undefined | null) {
if (!record) {
return null;
}
if (record.completedAt) {
return null;
}
return record;
}
async function getComplianceCompletionSnapshot(params: {
groupMemberId: string;
requiredExerciseId: string | null | undefined;
}) {
const { groupMemberId, requiredExerciseId } = params;
if (!requiredExerciseId) {
return {
score: null,
attempts: null
};
}
const { maxPoints } = await getExerciseTitleAndMaxPoints(requiredExerciseId);
const submissions = await getStudentSubmissionsForExercise(groupMemberId, requiredExerciseId);
const completedSubmissions = submissions.filter((submission) => submission.gradingState === 'completed');
if (completedSubmissions.length === 0 || maxPoints <= 0) {
return {
score: null,
attempts: submissions.length
};
}
const bestScorePercent = completedSubmissions.reduce((highestPercent, submission) => {
const totalPoints = Number(submission.total ?? 0);
const nextPercent = Math.round((totalPoints / maxPoints) * 100);
return Math.max(highestPercent, nextPercent);
}, 0);
return {
score: bestScorePercent,
attempts: submissions.length
};
}
async function ensureComplianceEnrollmentRecordForLearner(courseId: string, groupMemberId: string, profileId: string) {
const course = await getComplianceCourseOrThrow(courseId);
const dueDate = resolveComplianceInitialDueDate(course);
const [existingRecord] = await getLatestComplianceRecordsByProfiles(courseId, [profileId]);
if (existingRecord) {
return existingRecord;
}
return createCourseCompletionRecord({
courseId,
groupMemberId,
profileId,
cycleNumber: 1,
status: 'not_started',
dueDate,
attempts: 0,
timeSpentMinutes: 0
});
}
export async function ensureComplianceEnrollmentRecordsForProfiles(courseIds: string[], profileIds: string[]) {
if (courseIds.length === 0 || profileIds.length === 0) {
return {
createdCount: 0
};
}
let createdCount = 0;
for (const courseId of courseIds) {
const [course] = await getCourseById(courseId);
if (!course || course.type !== 'COMPLIANCE') {
continue;
}
resolveComplianceInitialDueDate(await getComplianceCourseOrThrow(courseId));
const learners = await getStudentCourseMembersForCompliance(courseId, profileIds);
for (const learner of learners) {
const profileId = learner.member.profileId;
if (!profileId) {
continue;
}
const [existingRecord] = await getLatestComplianceRecordsByProfiles(courseId, [profileId]);
if (existingRecord) {
continue;
}
await ensureComplianceEnrollmentRecordForLearner(courseId, learner.member.id, profileId);
createdCount += 1;
}
}
return {
createdCount
};
}
export async function syncComplianceProgressFromSubmission(courseId: string, groupMemberId: string) {
const [course] = await getCourseById(courseId);
if (!course || course.type !== 'COMPLIANCE') {
return null;
}
const profile = await getProfileByGroupMemberId(groupMemberId);
if (!profile) {
return null;
}
const activeRecord = await ensureComplianceEnrollmentRecordForLearner(courseId, groupMemberId, profile.id);
if (!activeRecord.completedAt && activeRecord.status === 'not_started') {
await updateCourseCompletionRecord(activeRecord.id, {
status: 'in_progress',
startedAt: activeRecord.startedAt ?? new Date().toISOString()
});
}
const evaluation = await evaluateCourseCertification(courseId, profile.id);
if (!evaluation.eligibleForCertificate) {
return {
status: 'in_progress',
completed: false
};
}
const latestRecord = await ensureComplianceEnrollmentRecordForLearner(courseId, groupMemberId, profile.id);
if (latestRecord.completedAt) {
return {
status: latestRecord.status,
completed: true
};
}
const completedAt = new Date().toISOString();
const validUntil = addMonths(completedAt, course.compliance?.retakeIntervalMonths ?? 0);
const completionSnapshot = await getComplianceCompletionSnapshot({
groupMemberId,
requiredExerciseId: course.certificate?.requiredExerciseId ?? null
});
await db.transaction(async (tx) => {
await updateCourseCompletionRecord(
latestRecord.id,
{
status: 'compliant',
startedAt: latestRecord.startedAt ?? completedAt,
completedAt,
validUntil,
score: completionSnapshot.score ?? latestRecord.score,
attempts: completionSnapshot.attempts ?? latestRecord.attempts
},
tx
);
if (course.certificate?.isDownloadable) {
await createCourseCertificateIssue(
{
courseId,
profileId: profile.id,
courseCompletionRecordId: latestRecord.id,
cycleNumber: latestRecord.cycleNumber,
expiresAt: validUntil,
status: 'valid'
},
tx
);
}
});
return {
status: 'compliant',
completed: true,
validUntil
};
}
export async function getCourseComplianceOverview(courseId: string) {
const course = await getComplianceCourseOrThrow(courseId);
const rows = await getCourseCurrentComplianceRows(courseId);
const counts = getStatusCounts(rows);
return {
courseId: course.id,
compliance: course.compliance,
summary: {
totalLearners: rows.length,
noRecord: counts.no_record,
notStarted: counts.not_started,
inProgress: counts.in_progress,
compliant: counts.compliant,
expiringSoon: counts.expiring_soon,
inGracePeriod: counts.in_grace_period,
nonCompliant: counts.non_compliant,
waived: counts.waived
},
learners: rows.map((row) => ({
groupMemberId: row.member.id,
profileId: row.member.profileId,
fullname: row.profile?.fullname ?? null,
username: row.profile?.username ?? null,
email: row.profile?.email ?? row.member.email ?? null,
status: row.record?.status ?? 'not_started',
cycleNumber: row.record?.cycleNumber ?? null,
dueDate: row.record?.dueDate ?? null,
completedAt: row.record?.completedAt ?? null,
validUntil: row.record?.validUntil ?? null,
score: row.record?.score ?? null,
attempts: row.record?.attempts ?? 0,
waiverExpiresAt: row.record?.waiverExpiresAt ?? null
}))
};
}
export async function getLearnerComplianceHistory(courseId: string, currentProfileId: string, targetProfileId: string) {
await getComplianceCourseOrThrow(courseId);
await assertCourseComplianceHistoryAccess(courseId, currentProfileId, targetProfileId);
const history = await getCourseComplianceHistoryRows(courseId, targetProfileId);
if (!history.member) {
throw new AppError('Learner not found in this course', ErrorCodes.NOT_FOUND, 404);
}
return {
learner: {
groupMemberId: history.member.id,
profileId: history.member.profileId,
fullname: history.profile?.fullname ?? null,
username: history.profile?.username ?? null,
avatarUrl: history.profile?.avatarUrl ?? null,
email: history.profile?.email ?? history.member.email ?? null
},
currentRecord: history.records[0] ?? null,
history: history.records
};
}
export async function resetCourseCompliance(courseId: string, payload: TCourseComplianceReset) {
await getComplianceCourseOrThrow(courseId);
ensureFutureOrPresentIsoDate(payload.dueDate, 'dueDate');
const students = await getTargetStudentMembers(courseId, payload.profileIds);
const profileIds = students.flatMap((student) => (student.member.profileId ? [student.member.profileId] : []));
const existingRecords = await getLatestComplianceRecordsByProfiles(courseId, profileIds);
const recordByProfileId = new Map(existingRecords.map((record) => [record.profileId, record]));
let createdCount = 0;
let updatedCount = 0;
await db.transaction(async (tx) => {
for (const student of students) {
const profileId = student.member.profileId;
if (!profileId) {
continue;
}
const existingRecord = recordByProfileId.get(profileId);
if (!existingRecord) {
await createCourseCompletionRecord(
{
courseId,
groupMemberId: student.member.id,
profileId,
cycleNumber: 1,
status: 'not_started',
dueDate: payload.dueDate,
attempts: 0,
timeSpentMinutes: 0
},
tx
);
createdCount += 1;
continue;
}
await updateCourseCompletionRecord(
existingRecord.id,
{
status: 'not_started',
dueDate: payload.dueDate,
startedAt: null,
completedAt: null,
validUntil: null,
expiredAt: null,
score: null,
attempts: 0,
timeSpentMinutes: 0,
waivedBy: null,
waiverReason: null,
waiverExpiresAt: null
},
tx
);
updatedCount += 1;
}
});
return {
createdCount,
updatedCount,
learnerCount: students.length,
dueDate: payload.dueDate
};
}
export async function extendCourseCompliance(courseId: string, payload: TCourseComplianceExtend) {
await getComplianceCourseOrThrow(courseId);
ensureFutureOrPresentIsoDate(payload.dueDate, 'dueDate');
const students = await getTargetStudentMembers(courseId, payload.profileIds);
const profileIds = students.flatMap((student) => (student.member.profileId ? [student.member.profileId] : []));
const existingRecords = await getLatestComplianceRecordsByProfiles(courseId, profileIds);
const recordByProfileId = new Map(existingRecords.map((record) => [record.profileId, record]));
let createdCount = 0;
let updatedCount = 0;
let skippedCount = 0;
await db.transaction(async (tx) => {
for (const student of students) {
const profileId = student.member.profileId;
if (!profileId) {
continue;
}
const existingRecord = getCurrentIncompleteRecord(recordByProfileId.get(profileId));
if (!existingRecord) {
const latestRecord = recordByProfileId.get(profileId);
if (latestRecord?.completedAt) {
skippedCount += 1;
continue;
}
await createCourseCompletionRecord(
{
courseId,
groupMemberId: student.member.id,
profileId,
cycleNumber: latestRecord ? latestRecord.cycleNumber + 1 : 1,
status: 'not_started',
dueDate: payload.dueDate,
attempts: 0,
timeSpentMinutes: 0
},
tx
);
createdCount += 1;
continue;
}
await updateCourseCompletionRecord(
existingRecord.id,
{
dueDate: payload.dueDate
},
tx
);
updatedCount += 1;
}
});
return {
createdCount,
updatedCount,
skippedCount,
learnerCount: students.length,
dueDate: payload.dueDate
};
}
export async function waiveCourseCompliance(courseId: string, actorProfileId: string, payload: TCourseComplianceWaive) {
await getComplianceCourseOrThrow(courseId);
if (payload.waiverExpiresAt) {
ensureFutureOrPresentIsoDate(payload.waiverExpiresAt, 'waiverExpiresAt');
}
const students = await getTargetStudentMembers(courseId, payload.profileIds);
const profileIds = students.flatMap((student) => (student.member.profileId ? [student.member.profileId] : []));
const existingRecords = await getLatestComplianceRecordsByProfiles(courseId, profileIds);
const recordByProfileId = new Map(existingRecords.map((record) => [record.profileId, record]));
let updatedCount = 0;
let skippedCount = 0;
await db.transaction(async (tx) => {
for (const student of students) {
const profileId = student.member.profileId;
if (!profileId) {
continue;
}
const existingRecord = getCurrentIncompleteRecord(recordByProfileId.get(profileId));
if (!existingRecord) {
skippedCount += 1;
continue;
}
await updateCourseCompletionRecord(
existingRecord.id,
{
status: 'waived',
waivedBy: actorProfileId,
waiverReason: payload.waiverReason ?? null,
waiverExpiresAt: payload.waiverExpiresAt ?? null
},
tx
);
updatedCount += 1;
}
});
return {
updatedCount,
skippedCount,
learnerCount: students.length,
waiverExpiresAt: payload.waiverExpiresAt ?? null
};
}
export async function runComplianceExpiryCheck() {
const nowIso = new Date().toISOString();
const records = await listComplianceRecordsReadyForExpiry(nowIso);
let processedCount = 0;
let createdCycleCount = 0;
let skippedCount = 0;
for (const record of records) {
if (!record.validUntil) {
skippedCount += 1;
continue;
}
const nextDueDate = record.validUntil;
await db.transaction(async (tx) => {
await updateCourseCompletionRecord(
record.id,
{
expiredAt: nowIso
},
tx
);
await updateCourseCertificateIssueStatusByRecordId(record.id, 'expired', tx);
const created = await createCourseCompletionRecord(
{
courseId: record.courseId,
groupMemberId: record.groupMemberId,
profileId: record.profileId,
cycleNumber: record.cycleNumber + 1,
status: 'not_started',
dueDate: nextDueDate,
attempts: 0,
timeSpentMinutes: 0
},
tx
).catch((error) => {
if (error instanceof Error && error.message.includes('duplicate key value')) {
return null;
}
throw error;
});
processedCount += 1;
if (created) {
createdCycleCount += 1;
} else {
skippedCount += 1;
}
});
}
return {
processedCount,
createdCycleCount,
skippedCount
};
}
export async function runComplianceReminderScan() {
const now = new Date();
const rows = await listLatestComplianceRecordsForReminderScan();
let scannedCount = 0;
let statusUpdatedCount = 0;
let reminderEventCount = 0;
for (const row of rows) {
scannedCount += 1;
const compliance = row.course.compliance as ComplianceSettings | null;
if (!compliance) {
continue;
}
if (row.record.status === 'waived') {
continue;
}
const dueDate = new Date(row.record.dueDate);
if (Number.isNaN(dueDate.getTime())) {
continue;
}
const daysUntilDue = getWholeDaysUntil(now, dueDate);
const gracePeriodDays = compliance.gracePeriodDays ?? 0;
let nextStatus: ComplianceStatus | null = null;
if (daysUntilDue < 0) {
const gracePeriodEndsAt = new Date(dueDate.getTime() + gracePeriodDays * 24 * 60 * 60 * 1000);
nextStatus = gracePeriodDays > 0 && now <= gracePeriodEndsAt ? 'in_grace_period' : 'non_compliant';
} else if ((compliance.reminderDaysBefore ?? []).includes(daysUntilDue)) {
nextStatus = 'expiring_soon';
const createdEvent = await createCourseCompletionNotificationEvent({
courseCompletionRecordId: row.record.id,
channel: 'email',
eventType: getReminderEventType(daysUntilDue)
});
if (createdEvent) {
reminderEventCount += 1;
}
}
if (nextStatus && row.record.status !== nextStatus) {
await updateCourseCompletionRecord(row.record.id, { status: nextStatus });
statusUpdatedCount += 1;
}
}
return {
scannedCount,
statusUpdatedCount,
reminderEventCount
};
}
+29 -4
View File
@@ -39,6 +39,26 @@ function buildContentKey(item: { id: string; type: string }) {
return `${item.type}-${item.id}`;
}
function normalizeDeleteItems<TItem extends { id: string; type: string }>(items: TItem[]): TItem[] {
const seenKeys = new Set<string>();
const uniqueItems: TItem[] = [];
for (const item of items) {
const itemKey = buildContentKey(item);
if (seenKeys.has(itemKey)) {
continue;
}
seenKeys.add(itemKey);
uniqueItems.push(item);
}
const exercises = uniqueItems.filter((item) => item.type === ContentType.Exercise);
const lessons = uniqueItems.filter((item) => item.type === ContentType.Lesson);
return [...exercises, ...lessons];
}
async function getCourseContentMap(courseId: string) {
const contentItems = await getCourseContentItems(courseId);
const contentMap = new Map<string, CourseContentItemRow>();
@@ -307,11 +327,14 @@ export async function deleteCourseContent(courseId: string, payload: TCourseCont
}
const contentItems = await getCourseContentItems(courseId);
const itemsToDelete = contentItems.filter(
(item) =>
(item.type === ContentType.Lesson || item.type === ContentType.Exercise) && item.sectionId === sectionId
const itemsToDelete = normalizeDeleteItems(
contentItems.filter(
(item) =>
(item.type === ContentType.Lesson || item.type === ContentType.Exercise) && item.sectionId === sectionId
)
);
console.log('Items to delete:', itemsToDelete);
await db.transaction(async (tx) => {
for (const item of itemsToDelete) {
if (item.type === ContentType.Lesson) {
@@ -324,6 +347,7 @@ export async function deleteCourseContent(courseId: string, payload: TCourseCont
const deleted = await deleteExercise(item.id, tx);
if (!deleted) {
console.log('delete exercise failed for id:', item.id);
throw new AppError('Exercise not found', ErrorCodes.EXERCISE_NOT_FOUND, 404);
}
}
@@ -343,9 +367,10 @@ export async function deleteCourseContent(courseId: string, payload: TCourseCont
const items = payload.items;
await assertCourseContentItems(courseId, items);
const normalizedItems = normalizeDeleteItems(items);
await db.transaction(async (tx) => {
for (const item of items) {
for (const item of normalizedItems) {
if (item.type === ContentType.Lesson) {
const deleted = await deleteLesson(item.id, tx);
if (!deleted) {
+4 -2
View File
@@ -20,6 +20,7 @@ import {
import { ContentType, ROLE } from '@cio/utils/constants';
import type { TCourse } from '@cio/db/types';
import type { TCourseCreate } from '@cio/utils/validation/course';
import { db } from '@cio/db/drizzle';
import * as schema from '@db/schema';
import { exerciseBelongsToCourse } from '@cio/db/queries/course/certification-exercise';
@@ -168,7 +169,7 @@ export async function getCourse(courseId?: string, slug?: string, profileId?: st
*/
export async function createCourse(
profileId: string,
data: { title: string; description: string; type: 'LIVE_CLASS' | 'SELF_PACED'; organizationId: string }
data: TCourseCreate
): Promise<{ course: TCourse; groupId: string; memberId: string }> {
try {
const description = sanitizeHtml(data.description);
@@ -192,7 +193,8 @@ export async function createCourse(
title: data.title,
description,
type: data.type,
groupId: newGroup.id
groupId: newGroup.id,
compliance: data.compliance ? sanitizeUnknownStrings(data.compliance) : undefined
},
tx
);
@@ -0,0 +1,263 @@
import { getCourseById } from '@cio/db/queries/course';
import { getCourseContentItems, type CourseContentItemRow } from '@cio/db/queries/course/content';
import { getCourseOrganizationId } from '@cio/db/queries/tag';
import { getOrganizationById } from '@cio/db/queries/organization';
import type { TCourse, TOrganization } from '@cio/db/types';
import { generateSlug } from '@cio/utils/functions';
import { ContentType } from '@cio/utils/constants';
import type { TCourseLandingPageUpdate } from '@cio/utils/validation/course';
import { AppError, ErrorCodes } from '@api/utils/errors';
import { updateCourse } from './course';
import { buildCourseBaseUrl, ensureCourseSlug, generateUniqueCourseSlug } from './landing-page';
export type CourseGoLiveIssue = {
code: string;
message: string;
target?: string;
};
export type CourseGoLiveReadiness = {
ready: boolean;
blockers: CourseGoLiveIssue[];
warnings: CourseGoLiveIssue[];
suggestedFixes: {
slug?: string;
landingPage?: TCourseLandingPageUpdate;
};
courseUrl?: string;
};
type CourseReadinessInput = {
course: Pick<
TCourse,
'id' | 'title' | 'description' | 'overview' | 'slug' | 'logo' | 'bannerImage' | 'metadata' | 'type' | 'cost'
>;
contentItems: CourseContentItemRow[];
suggestedSlug?: string;
organization?: Pick<TOrganization, 'customDomain' | 'isCustomDomainVerified' | 'siteName'> | null;
};
const PLACEHOLDER_PATTERNS = [
/\[edit me\]/i,
/^untitled\b/i,
/^new course$/i,
/^course title$/i,
/^course description$/i,
/welcome to this amazing course/i
];
function normalizeReadableText(value: string | null | undefined): string {
return (value ?? '')
.replace(/<[^>]*>/g, ' ')
.replace(/&nbsp;|&#160;/gi, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function hasMeaningfulText(value: string | null | undefined): boolean {
const normalized = normalizeReadableText(value);
if (normalized.length < 3) {
return false;
}
return !PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(normalized));
}
function buildIssue(code: string, message: string, target?: string): CourseGoLiveIssue {
return target ? { code, message, target } : { code, message };
}
function hasLessonContent(item: CourseContentItemRow): boolean {
return Boolean(
item.hasNoteContent || item.hasSlideContent || (item.videosCount ?? 0) > 0 || (item.documentsCount ?? 0) > 0
);
}
function buildCourseUrl(
organization: CourseReadinessInput['organization'],
slug: string | null | undefined
): string | undefined {
if (!organization || !slug) {
return undefined;
}
return `${buildCourseBaseUrl(organization)}/course/${encodeURIComponent(slug)}`;
}
export function evaluateCourseGoLiveReadiness(input: CourseReadinessInput): CourseGoLiveReadiness {
const { course, contentItems, organization, suggestedSlug } = input;
const blockers: CourseGoLiveIssue[] = [];
const warnings: CourseGoLiveIssue[] = [];
const landingPageFixes: TCourseLandingPageUpdate = {};
const metadataFixes: NonNullable<TCourseLandingPageUpdate['metadata']> = {};
if (!hasMeaningfulText(course.title)) {
blockers.push(buildIssue('COURSE_TITLE_MISSING', 'Add a clear course title.', 'course.title'));
}
if (!hasMeaningfulText(course.description)) {
blockers.push(buildIssue('COURSE_DESCRIPTION_MISSING', 'Add a clear course description.', 'course.description'));
}
if (!course.slug && !suggestedSlug) {
blockers.push(buildIssue('COURSE_SLUG_MISSING', 'Generate a public course slug.', 'course.slug'));
}
if (!hasMeaningfulText(course.overview)) {
blockers.push(buildIssue('LANDING_OVERVIEW_MISSING', 'Add a landing-page overview.', 'course.overview'));
landingPageFixes.overview = '';
}
if (!hasMeaningfulText(course.metadata?.description)) {
blockers.push(
buildIssue('LANDING_DESCRIPTION_MISSING', 'Add a landing-page description.', 'course.metadata.description')
);
metadataFixes.description = '';
}
if (!hasMeaningfulText(course.metadata?.goals)) {
blockers.push(
buildIssue('LANDING_GOALS_MISSING', 'Add course goals for the landing page.', 'course.metadata.goals')
);
metadataFixes.goals = '';
}
if (!hasMeaningfulText(course.metadata?.requirements)) {
blockers.push(
buildIssue(
'LANDING_REQUIREMENTS_MISSING',
'Add course requirements for the landing page.',
'course.metadata.requirements'
)
);
metadataFixes.requirements = '';
}
if (!course.logo && !course.bannerImage) {
blockers.push(buildIssue('LANDING_IMAGE_MISSING', 'Add a landing-page banner or course image.', 'course.logo'));
landingPageFixes.generateImage = true;
}
const learningItems = contentItems.filter(
(item) => item.type === ContentType.Lesson || item.type === ContentType.Exercise
);
if (learningItems.length === 0) {
blockers.push(buildIssue('COURSE_CONTENT_MISSING', 'Add at least one lesson or exercise.', 'course.content'));
}
for (const item of contentItems) {
if (item.type === ContentType.Lesson && !hasLessonContent(item)) {
blockers.push(
buildIssue(
'LESSON_CONTENT_EMPTY',
`Add content to lesson "${item.title ?? 'Untitled lesson'}".`,
`lesson:${item.id}`
)
);
}
if (item.type === ContentType.Exercise && (item.questionCount ?? 0) === 0) {
blockers.push(
buildIssue(
'EXERCISE_QUESTIONS_MISSING',
`Add at least one question to exercise "${item.title ?? 'Untitled exercise'}".`,
`exercise:${item.id}`
)
);
}
}
if (!hasMeaningfulText(course.metadata?.instructor?.name)) {
warnings.push(
buildIssue('INSTRUCTOR_MISSING', 'Add instructor information for more trust.', 'course.metadata.instructor')
);
}
if (!course.metadata?.reviews?.some((review) => !review.hide && hasMeaningfulText(review.description))) {
warnings.push(
buildIssue('REVIEWS_MISSING', 'Add testimonials or reviews when available.', 'course.metadata.reviews')
);
}
if ((course.cost ?? 0) > 0 && !hasMeaningfulText(course.metadata?.paymentLink)) {
warnings.push(
buildIssue('PAYMENT_LINK_MISSING', 'Add a payment link for this paid course.', 'course.metadata.paymentLink')
);
}
if (course.type === 'COMPLIANCE') {
warnings.push(
buildIssue('COMPLIANCE_REVIEW_RECOMMENDED', 'Review compliance settings before publishing.', 'course.compliance')
);
}
if (Object.keys(metadataFixes).length > 0) {
landingPageFixes.metadata = metadataFixes;
}
const suggestedFixes: CourseGoLiveReadiness['suggestedFixes'] = {};
if (!course.slug && suggestedSlug) {
suggestedFixes.slug = suggestedSlug;
}
if (Object.keys(landingPageFixes).length > 0) {
suggestedFixes.landingPage = landingPageFixes;
}
return {
ready: blockers.length === 0,
blockers,
warnings,
suggestedFixes,
courseUrl: buildCourseUrl(organization, course.slug ?? suggestedSlug)
};
}
export async function getCourseGoLiveReadiness(courseId: string): Promise<CourseGoLiveReadiness> {
const [course] = await getCourseById(courseId);
if (!course) {
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
}
const organizationId = await getCourseOrganizationId(courseId);
const [contentItems, organization] = await Promise.all([
getCourseContentItems(courseId),
organizationId ? getOrganizationById(organizationId) : Promise.resolve(null)
]);
const suggestedSlug = course.slug
? undefined
: await generateUniqueCourseSlug(generateSlug(course.title, { fallback: 'course' }));
return evaluateCourseGoLiveReadiness({
course,
contentItems,
suggestedSlug,
organization
});
}
export async function publishCourseWhenReady(courseId: string) {
const readiness = await getCourseGoLiveReadiness(courseId);
if (!readiness.ready) {
const blockerList = readiness.blockers.map((blocker) => blocker.message).join(' ');
throw new AppError(`Course is not ready to go live. ${blockerList}`, 'COURSE_GO_LIVE_BLOCKED', 400);
}
const [course] = await getCourseById(courseId);
if (!course) {
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
}
const slug = await ensureCourseSlug(courseId, course.title);
const publishedCourse = await updateCourse(courseId, { slug, isPublished: true });
const publishedReadiness = await getCourseGoLiveReadiness(courseId);
return {
course: publishedCourse,
readiness: publishedReadiness
};
}
+7
View File
@@ -25,6 +25,7 @@ import { getProfileById } from '@cio/db/queries/auth';
import { buildEmailFromName, sendEmail } from '@cio/email';
import { getProfileByEmail, markUserAndProfileEmailVerified } from '@cio/db/queries/auth';
import { generateSlug } from '@cio/utils/functions';
import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance';
type InviteStatus = 'ACTIVE' | 'EXPIRED' | 'USED_UP' | 'REVOKED';
type InvitePreset = 'ONE_TIME_24H' | 'MULTI_USE_7D' | 'MULTI_USE_30D' | 'CUSTOM';
@@ -647,6 +648,8 @@ export async function enrollInCourse(
.limit(1);
if (existingMember) {
await ensureComplianceEnrollmentRecordsForProfiles([courseId], [user.id]);
return {
success: true,
alreadyJoined: true,
@@ -677,6 +680,8 @@ export async function enrollInCourse(
email: normalizedEmail
});
await ensureComplianceEnrollmentRecordsForProfiles([courseId], [user.id]);
await sendStudentJoinEmails({
courseId,
courseName: title,
@@ -1071,6 +1076,8 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
};
});
await ensureComplianceEnrollmentRecordsForProfiles([result.courseId], [user.id]);
if (!result.alreadyJoined) {
await sendStudentJoinEmails({
courseId: result.courseId,
+6 -4
View File
@@ -20,7 +20,7 @@ type CourseLandingPageUpdateResult = {
bannerImageUrl: string | null;
};
async function generateUniqueCourseSlug(baseSlug: string): Promise<string> {
export async function generateUniqueCourseSlug(baseSlug: string): Promise<string> {
let suffix = 0;
let candidate = baseSlug;
@@ -40,7 +40,7 @@ async function generateUniqueCourseSlug(baseSlug: string): Promise<string> {
}
}
async function ensureCourseSlug(courseId: string, title: string | null | undefined): Promise<string> {
export async function ensureCourseSlug(courseId: string, title: string | null | undefined): Promise<string> {
const [course] = await getCourseById(courseId);
if (course?.slug) {
return course.slug;
@@ -52,7 +52,9 @@ async function ensureCourseSlug(courseId: string, title: string | null | undefin
return uniqueSlug;
}
function buildCourseBaseUrl(organization: TOrganization) {
export function buildCourseBaseUrl(
organization: Pick<TOrganization, 'customDomain' | 'isCustomDomainVerified' | 'siteName'>
) {
if (organization.customDomain && organization.isCustomDomainVerified) {
return `https://${organization.customDomain}`;
}
@@ -60,7 +62,7 @@ function buildCourseBaseUrl(organization: TOrganization) {
return getDashboardBaseUrl(organization.siteName ?? undefined);
}
async function resolveCourseUrl(organizationId: string, courseId: string, title: string) {
export 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);
+12
View File
@@ -15,6 +15,7 @@ import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { getCourseWithOrgData } from '@cio/db/queries/course';
import { getProfileById } from '@cio/db/queries/auth';
import { buildEmailFromName, sendEmail } from '@cio/email';
import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance';
/**
* Gets all course members (people) for a course
@@ -53,6 +54,10 @@ export async function addMember(
const addedMember = await addCourseMember(courseId, data);
if (data.roleId === ROLE.STUDENT && data.profileId) {
await ensureComplianceEnrollmentRecordsForProfiles([courseId], [data.profileId]);
}
// Send emails when a student joins
if (data.roleId === ROLE.STUDENT) {
try {
@@ -176,6 +181,13 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) {
// Add all members
const addedMembers = await Promise.all(members.map((member) => addCourseMember(courseId, member)));
const studentProfileIds = members
.filter((member) => member.roleId === ROLE.STUDENT && member.profileId)
.map((member) => member.profileId!);
if (studentProfileIds.length > 0) {
await ensureComplianceEnrollmentRecordsForProfiles([courseId], studentProfileIds);
}
// Send welcome emails for teachers (TUTOR role = 2, ADMIN role = 1)
const baseUrl = getDashboardBaseUrl(orgSiteName);
+103
View File
@@ -1,10 +1,15 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { env } from '@api/config/env';
import {
getCourseStats,
getDashOrgStats,
getOrgStudentLoginsByDayOfWeek,
getUserLoginStreak,
getRecentCertifications,
getTotalCertificatesIssued
} from '@cio/db/queries/dash';
import { dashLoginActivityKey } from '@api/utils/redis/key-generators';
import { logRedisUnavailableOnce, redis } from '@api/utils/redis/redis';
import { OrganisationAnalytics } from '@api/types';
import { getOrgIdBySiteName } from '@cio/db/queries';
@@ -65,3 +70,101 @@ export async function getOrganisationAnalytics(orgId?: string, siteName?: string
throw new AppError('Failed to load organisation analytics', ErrorCodes.ORG_ANALYTICS_FETCH_FAILED, 500);
}
}
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const;
type StudentLoginActivityRow = { day: string; count: number };
/** Redis TTL for login-activity chart payload (1 day). */
const LOGIN_ACTIVITY_CACHE_TTL_SECONDS = 86_400;
function parseLoginActivityCache(raw: string): StudentLoginActivityRow[] | null {
let parsed: unknown;
try {
parsed = JSON.parse(raw) as unknown;
} catch {
return null;
}
if (!Array.isArray(parsed) || parsed.length !== 7) {
return null;
}
const out: StudentLoginActivityRow[] = [];
for (const item of parsed) {
if (
typeof item !== 'object' ||
item === null ||
!('day' in item) ||
!('count' in item) ||
typeof (item as { day: unknown }).day !== 'string' ||
typeof (item as { count: unknown }).count !== 'number' ||
!Number.isInteger((item as { count: number }).count) ||
(item as { count: number }).count < 0
) {
return null;
}
out.push({ day: (item as { day: string }).day, count: (item as { count: number }).count });
}
for (const label of DAY_LABELS) {
if (!out.some((r) => r.day === label)) {
return null;
}
}
return out;
}
/** Day-of-week login chart; results cached in Redis (24h TTL) when `REDIS_URL` is set. */
export async function getStudentLoginActivity(orgId: string, days: number): Promise<StudentLoginActivityRow[]> {
const cacheKey = dashLoginActivityKey(orgId, days);
if (env.REDIS_URL) {
try {
const cached = await redis.get(cacheKey);
if (cached) {
const rows = parseLoginActivityCache(cached);
if (rows) {
return rows;
}
}
} catch (error) {
logRedisUnavailableOnce('Redis get failed for login activity cache, using database', error);
}
}
try {
const rows = await getOrgStudentLoginsByDayOfWeek(orgId, days);
const countByDow = new Map(rows.map((r) => [r.dayOfWeek, r.count]));
const result = DAY_LABELS.map((label, index) => ({
day: label,
count: countByDow.get(index) ?? 0
}));
if (env.REDIS_URL) {
try {
await redis.setEx(cacheKey, LOGIN_ACTIVITY_CACHE_TTL_SECONDS, JSON.stringify(result));
} catch (error) {
logRedisUnavailableOnce('Redis set failed for login activity cache, continuing', error);
}
}
return result;
} catch (error) {
console.error('getStudentLoginActivity error:', error);
throw new AppError('Failed to load login activity', ErrorCodes.ORG_ANALYTICS_FETCH_FAILED, 500);
}
}
export async function getCurrentUserLoginStreak(userId: string) {
try {
return await getUserLoginStreak(userId);
} catch (error) {
console.error('getCurrentUserLoginStreak error:', error);
throw new AppError('Failed to load login streak', ErrorCodes.ORG_ANALYTICS_FETCH_FAILED, 500);
}
}
@@ -2,6 +2,7 @@ import { AppError, ErrorCodes } from '@api/utils/errors';
import { sanitizeHtml, sanitizeOptionalHtml, sanitizeUnknownStrings } from '@api/utils/sanitize-html';
import type { TExercise, TExerciseTemplate, TNewOption, TNewQuestion } from '@cio/db/types';
import type { TExerciseCreate, TExerciseUpdate } from '@cio/utils/validation/exercise';
import { getLessonById } from '@cio/db/queries/lesson';
import {
createExercises,
syncOptionIdSequence,
@@ -65,6 +66,20 @@ function sanitizeExercisePayload(data: TExerciseCreate | TExerciseUpdate): TExer
};
}
async function resolveExerciseCourseId(exercise: TExercise): Promise<string | null> {
if (exercise.courseId) {
return exercise.courseId;
}
if (!exercise.lessonId) {
return null;
}
const lesson = await getLessonById(exercise.lessonId);
return lesson?.courseId ?? null;
}
/**
* Creates a new exercise with optional questions and options
* @param data Exercise creation data
@@ -373,6 +388,39 @@ export async function deleteExerciseService(exerciseId: string): Promise<TExerci
}
}
export async function deleteExerciseForCourseService(courseId: string, exerciseId: string): Promise<TExercise> {
try {
const exercise = await getExerciseById(exerciseId);
if (!exercise) {
throw new AppError('Exercise not found', ErrorCodes.EXERCISE_NOT_FOUND, 404);
}
const exerciseCourseId = await resolveExerciseCourseId(exercise);
if (exerciseCourseId !== courseId) {
throw new AppError('Exercise does not belong to this course', ErrorCodes.EXERCISE_NOT_FOUND, 404);
}
const deleted = await deleteExercise(exerciseId);
if (!deleted) {
throw new AppError('Failed to delete exercise', ErrorCodes.INTERNAL_ERROR, 500);
}
await touchCourseUpdatedAt(courseId);
return deleted;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to delete exercise',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
/**
* Lists exercises for a course
* @param courseId Course ID
@@ -28,6 +28,7 @@ import { ROLE } from '@cio/utils/constants';
import crypto from 'node:crypto';
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { getProfilesByEmails } from '@cio/db/queries/auth';
import { ensureComplianceEnrollmentRecordsForProfiles } from '../course/compliance';
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const ORG_INVITE_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
@@ -188,6 +189,10 @@ async function enrollAudienceStudentProfilesInCourses(
);
}
if (validProfiles.length > 0) {
await ensureComplianceEnrollmentRecordsForProfiles(courseIds, validProfiles);
}
let emailsSent = 0;
const loginUrl = getDashboardBaseUrl(organization.siteName ?? undefined);
@@ -534,6 +539,10 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
if (profiles.length > 0) {
const users = profiles.map((p) => ({ profileId: p.id, email: p.email ?? undefined }));
await enrollUsersInCourseGroups(validGroupIds, users, ROLE.STUDENT);
await ensureComplianceEnrollmentRecordsForProfiles(
courseIds,
profiles.map((profile) => profile.id)
);
}
}
}
@@ -23,6 +23,7 @@ import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { parseCourseIdsFromInviteMetadata, parseProgramIdsFromInviteMetadata } from '@api/utils/org';
import { markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile';
import { sendEmail } from '@cio/email';
import { ensureComplianceEnrollmentRecordsForProfiles } from '../course/compliance';
type OrganizationInviteStatus = 'ACTIVE' | 'EXPIRED' | 'REVOKED' | 'ACCEPTED';
@@ -440,6 +441,7 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
[{ profileId: user.id, email: normalizedEmail }],
invite.invite.roleId
);
await ensureComplianceEnrollmentRecordsForProfiles(courseIds, [user.id]);
} catch (error) {
console.error('acceptOrganizationInvite course enrollment error:', error);
}
@@ -652,6 +654,7 @@ export async function acceptOrganizationInviteById(
[{ profileId: user.id, email: normalizedEmail }],
result.invite.roleId
);
await ensureComplianceEnrollmentRecordsForProfiles(courseIds, [user.id]);
} catch (error) {
console.error('acceptOrganizationInviteById course enrollment error:', error);
}
@@ -0,0 +1,39 @@
import {
searchLmsCourses,
searchLmsPrograms,
searchOrgAudience,
searchOrgCourses,
searchOrgPrograms,
searchOrgTagsAndGroups,
searchOrgWidgets
} from '@cio/db/queries';
export async function searchOrganization(orgId: string, search: string, limit: number) {
const [courses, programs, widgets, tags, audience] = await Promise.all([
searchOrgCourses(orgId, search, limit),
searchOrgPrograms(orgId, search, limit),
searchOrgWidgets(orgId, search, limit),
searchOrgTagsAndGroups(orgId, search, limit),
searchOrgAudience(orgId, search, limit)
]);
return {
courses,
programs,
widgets,
tags,
audience
};
}
export async function searchLmsOrganization(orgId: string, profileId: string, search: string, limit: number) {
const [courses, programs] = await Promise.all([
searchLmsCourses(orgId, profileId, search, limit),
searchLmsPrograms(orgId, profileId, search, limit)
]);
return {
courses,
programs
};
}
+16 -1
View File
@@ -36,6 +36,7 @@ import { QUESTION_TYPE_ID_TO_KEY } from '@cio/question-types';
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { generateDocumentDownloadPresignedUrls } from '@api/utils/s3';
import { syncComplianceProgressFromSubmission } from '@api/services/course/compliance';
type SubmissionGradingState = 'queued' | 'processing' | 'awaiting_manual' | 'completed' | 'failed';
type SubmissionOverallStatus = 'auto_graded' | 'manual_required' | 'hybrid';
@@ -573,7 +574,9 @@ export async function createSubmissionService(
}
const shouldAutoGrade =
course?.type === 'SELF_PACED' && overallStatus === 'auto_graded' && exerciseWithRelations.questions.length > 0;
(course?.type === 'SELF_PACED' || course?.type === 'COMPLIANCE') &&
overallStatus === 'auto_graded' &&
exerciseWithRelations.questions.length > 0;
if (shouldAutoGrade) {
const answeredIds = new Set(answerByQuestionId.keys());
@@ -631,6 +634,8 @@ export async function createSubmissionService(
console.error('Failed to send exercise submission update email:', emailError);
});
await syncComplianceProgressFromSubmission(courseId, submittedBy);
const enrichedAnswers =
answersWithPoints.length > 0 ? await enrichFileUploadAnswersArray(answersWithPoints) : answersWithPoints;
return { ...submission, answers: enrichedAnswers };
@@ -641,6 +646,8 @@ export async function createSubmissionService(
console.error('Failed to send exercise submission update email:', emailError);
});
await syncComplianceProgressFromSubmission(courseId, submittedBy);
return submission;
} catch (error) {
if (error instanceof AppError) {
@@ -695,6 +702,10 @@ export async function updateSubmissionService(submissionId: string, data: TSubmi
});
}
if (updated.gradingState === 'completed' && updated.courseId && updated.submittedBy) {
await syncComplianceProgressFromSubmission(updated.courseId, updated.submittedBy);
}
return updated;
} catch (error) {
if (error instanceof AppError) {
@@ -793,6 +804,10 @@ export async function updateSubmissionGradesBatch(
});
}
if (updated.courseId && updated.submittedBy) {
await syncComplianceProgressFromSubmission(updated.courseId, updated.submittedBy);
}
return updated;
} catch (error) {
if (error instanceof AppError) {
+434
View File
@@ -0,0 +1,434 @@
import { getOrgCourses } from '@cio/db/queries/course';
import { getActiveOrganizationPlan, getOrganizationById } from '@cio/db/queries/organization';
import { getCourseTagsByCourseIdsForOrganization, getTagGroupsWithTags } from '@cio/db/queries/tag';
import { listWidgetCourses } from '@cio/db/queries/widget';
import { PLAN } from '@cio/utils/plans';
import {
ZWidgetPayload,
getLayoutCourseLimit,
normalizeWidgetConfig,
resolveWidgetPlanGatedFields,
type TWidgetLayoutType,
type TWidgetPayload,
type TWidgetPayloadCategory,
type TWidgetPayloadCourseTag
} from '@cio/utils/validation/widget';
import type { TWidget } from '@db/types';
import * as csstree from 'css-tree';
import { env } from '@api/config/env';
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
const DEFAULT_EMBED_CDN_BASE_URL = 'https://assets.cdn.clsrio.com';
const DEFAULT_LOCAL_EMBED_BASE_URL = 'http://localhost:5180';
const DEFAULT_WIDGET_API_BASE_URL = 'https://api.classroomio.com';
const DEFAULT_WIDGET_APP_BASE_URL = 'https://classroomio.com';
const ALLOWED_CUSTOM_CSS_PROPERTIES = new Set([
'align-items',
'background',
'background-color',
'border',
'border-color',
'border-radius',
'border-style',
'border-width',
'box-shadow',
'color',
'display',
'fill',
'flex',
'flex-basis',
'flex-direction',
'flex-grow',
'flex-shrink',
'font',
'font-family',
'font-size',
'font-style',
'font-weight',
'gap',
'grid',
'grid-template-columns',
'grid-template-rows',
'height',
'justify-content',
'letter-spacing',
'line-height',
'margin',
'margin-top',
'margin-right',
'margin-bottom',
'margin-left',
'max-height',
'max-width',
'min-height',
'min-width',
'object-fit',
'opacity',
'overflow',
'padding',
'padding-top',
'padding-right',
'padding-bottom',
'padding-left',
'place-items',
'stroke',
'text-align',
'text-decoration',
'text-transform',
'transform',
'transition',
'width',
'white-space'
]);
function encodeBase58(bytes: Uint8Array): string {
let value = 0n;
for (const byte of bytes) {
value = (value << 8n) + BigInt(byte);
}
if (value === 0n) {
return BASE58_ALPHABET[0];
}
let encoded = '';
while (value > 0n) {
const remainder = Number(value % 58n);
encoded = BASE58_ALPHABET[remainder] + encoded;
value /= 58n;
}
return encoded;
}
export function generateWidgetPublicKey(): string {
const bytes = crypto.getRandomValues(new Uint8Array(16));
return `wgt_${encodeBase58(bytes).slice(0, 16)}`;
}
function sanitizeCustomCssValue(customCss: string, planName?: string | null): string {
if (!customCss.trim() || !planName || planName === PLAN.BASIC) {
return '';
}
if (customCss.length > 5_000) {
throw new Error('Custom CSS exceeds 5KB limit');
}
const normalizedCss = customCss.toLowerCase();
const bannedSnippets = ['@import', 'javascript:', 'expression(', 'behavior:', 'binding:', '-moz-binding'];
if (bannedSnippets.some((snippet) => normalizedCss.includes(snippet))) {
throw new Error('Custom CSS contains unsupported rules');
}
const ast = csstree.parse(customCss, { context: 'stylesheet' });
csstree.walk(ast, (node: any) => {
if (node.type === 'Atrule') {
throw new Error('At-rules are not allowed in custom CSS');
}
if (node.type === 'Declaration') {
const propertyName = node.property.toLowerCase();
const propertyValue = csstree.generate(node.value).toLowerCase();
if (!ALLOWED_CUSTOM_CSS_PROPERTIES.has(propertyName)) {
throw new Error(`Custom CSS property "${propertyName}" is not allowed`);
}
if (propertyName === 'position' && propertyValue.includes('fixed')) {
throw new Error('position: fixed is not allowed in custom CSS');
}
}
});
return csstree.generate(ast);
}
function getCourseBaseUrl(siteName: string, customDomain?: string | null): string {
if (customDomain) {
return `https://${customDomain}`;
}
return siteName ? `https://${siteName}.classroomio.com` : DEFAULT_WIDGET_APP_BASE_URL;
}
export function getCourseWidgetScriptUrl(): string {
const embedBaseUrl = (
env.PUBLIC_EMBED_BASE_URL ||
(env.NODE_ENV === 'development' ? DEFAULT_LOCAL_EMBED_BASE_URL : DEFAULT_EMBED_CDN_BASE_URL)
).replace(/\/$/, '');
return `${embedBaseUrl}/embeds/course-widget/course-widget.js`;
}
export function getWidgetApiBaseUrl(): string {
return (env.PUBLIC_SERVER_URL || DEFAULT_WIDGET_API_BASE_URL).replace(/\/$/, '');
}
export function getWidgetEmbedCode(publicKey: string): string {
const scriptUrl = getCourseWidgetScriptUrl();
const apiBaseUrl = getWidgetApiBaseUrl();
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>`;
}
export async function buildWidgetPayload(
widget: TWidget,
overrideSelectedCourseIds?: string[]
): Promise<TWidgetPayload> {
const organization = await getOrganizationById(widget.organizationId);
if (!organization) {
throw new Error('Organization not found for widget');
}
const activePlan = await getActiveOrganizationPlan(widget.organizationId);
const normalizedConfig = normalizeWidgetConfig(widget.config as Record<string, unknown>, activePlan?.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 tagsByCourseId = await getCourseTagsByCourseIdsForOrganization(
widget.organizationId,
resolvedCourses.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));
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 orgBaseUrl = getCourseBaseUrl(organization.siteName ?? '', organization.customDomain);
return ZWidgetPayload.parse({
version: 'v1',
widgetId: widget.id,
publicKey: widget.publicKey,
organization: {
id: organization.id,
name: organization.name,
siteName: organization.siteName,
customDomain: organization.customDomain
},
layoutType,
selectionMode: widget.selectionMode,
design: {
...normalizedConfig,
advanced: {
...normalizedConfig.advanced,
customCss: sanitizedCustomCss
}
},
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()
});
}
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 }),
getTagGroupsWithTags(orgId),
getActiveOrganizationPlan(orgId)
]);
const tagsByCourseId = await getCourseTagsByCourseIdsForOrganization(
orgId,
coursesResult.items.map((course) => course.id)
);
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] ?? []
})),
availableTags: tagGroups.flatMap((group) =>
group.tags.map((tag) => ({
id: tag.id,
name: tag.name,
slug: tag.slug,
color: tag.color,
groupId: group.id
}))
),
planGatedFields: resolveWidgetPlanGatedFields(activePlan?.planName, 'classroomio')
};
}
+335
View File
@@ -0,0 +1,335 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import {
archiveWidget,
createWidget,
createWidgetVersion,
getWidgetListItemById,
getNextWidgetVersion,
getPublishedWidgetPayloadByPublicKey,
getWidgetById,
getWidgetVersionById,
listWidgetCourses,
listWidgetVersions,
listWidgetsByOrganization,
replaceWidgetCourses,
updateWidget
} from '@cio/db/queries/widget';
import {
ZWidgetPayload,
getDefaultWidgetConfig,
normalizeWidgetConfig,
type TCreateWidget,
type TPreviewWidget,
type TRollbackWidget,
type TUpdateWidget
} from '@cio/utils/validation/widget';
import {
buildWidgetPayload,
generateWidgetPublicKey,
getCourseWidgetScriptUrl,
getWidgetEmbedCode,
listWidgetAvailableEditorData
} from './widget-payload';
export async function listOrganizationWidgets(orgId: string) {
try {
return await listWidgetsByOrganization(orgId);
} catch (error) {
throw new AppError(
error instanceof Error ? error.message : 'Failed to list widgets',
ErrorCodes.WIDGET_FETCH_FAILED,
500
);
}
}
export async function createOrganizationWidget(orgId: string, userId: string, data: TCreateWidget) {
try {
const widget = await createWidget({
organizationId: orgId,
name: data.name,
layoutType: data.layoutType,
selectionMode: data.selectionMode,
publicKey: generateWidgetPublicKey(),
config: normalizeWidgetConfig(data.config ?? getDefaultWidgetConfig()),
createdByUserId: userId,
updatedByUserId: userId
});
await replaceWidgetCourses(widget.id, data.selectedCourseIds);
const listItem = await getWidgetListItemById(orgId, widget.id);
if (!listItem) {
throw new AppError('Widget not found after creation', ErrorCodes.WIDGET_NOT_FOUND, 404);
}
return listItem;
} catch (error) {
throw new AppError(
error instanceof Error ? error.message : 'Failed to create widget',
ErrorCodes.WIDGET_CREATE_FAILED,
500
);
}
}
export async function getOrganizationWidgetDetail(orgId: string, widgetId: string) {
try {
const widget = await getWidgetById(orgId, widgetId);
if (!widget) {
throw new AppError('Widget not found', ErrorCodes.WIDGET_NOT_FOUND, 404);
}
const [selectedCourses, versions, editorData, previewPayload] = await Promise.all([
listWidgetCourses(widget.id),
listWidgetVersions(orgId, widget.id),
listWidgetAvailableEditorData(orgId),
buildWidgetPayload(widget)
]);
return {
widget: {
...widget,
config: normalizeWidgetConfig(widget.config as Record<string, unknown>),
selectedCourseIds: selectedCourses.map((course) => course.courseId),
embedCode: getWidgetEmbedCode(widget.publicKey),
publicScriptUrl: getCourseWidgetScriptUrl()
},
availableCourses: editorData.availableCourses,
availableTags: editorData.availableTags,
previewPayload,
versions,
planGatedFields: editorData.planGatedFields
};
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to get widget detail',
ErrorCodes.WIDGET_FETCH_FAILED,
500
);
}
}
export async function updateOrganizationWidget(orgId: string, widgetId: string, userId: string, data: TUpdateWidget) {
try {
const existingWidget = await getWidgetById(orgId, widgetId);
if (!existingWidget) {
throw new AppError('Widget not found', ErrorCodes.WIDGET_NOT_FOUND, 404);
}
const updatedWidget = await updateWidget(orgId, widgetId, {
name: data.name ?? existingWidget.name,
layoutType: data.layoutType ?? existingWidget.layoutType,
selectionMode: data.selectionMode ?? existingWidget.selectionMode,
config: normalizeWidgetConfig((data.config ?? existingWidget.config) as Record<string, unknown>),
hasUnpublishedChanges: true,
updatedByUserId: userId
});
if (!updatedWidget) {
throw new AppError('Widget not found', ErrorCodes.WIDGET_NOT_FOUND, 404);
}
if (data.selectedCourseIds) {
await replaceWidgetCourses(widgetId, data.selectedCourseIds);
}
const listItem = await getWidgetListItemById(orgId, widgetId);
if (!listItem) {
throw new AppError('Widget not found after update', ErrorCodes.WIDGET_NOT_FOUND, 404);
}
return listItem;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to update widget',
ErrorCodes.WIDGET_UPDATE_FAILED,
500
);
}
}
export async function deleteOrganizationWidget(orgId: string, widgetId: string, userId: string) {
try {
const archivedWidget = await archiveWidget(orgId, widgetId, userId);
if (!archivedWidget) {
throw new AppError('Widget not found', ErrorCodes.WIDGET_NOT_FOUND, 404);
}
return archivedWidget;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to delete widget',
ErrorCodes.WIDGET_DELETE_FAILED,
500
);
}
}
export async function publishOrganizationWidget(orgId: string, widgetId: string, userId: string) {
try {
const widget = await getWidgetById(orgId, widgetId);
if (!widget) {
throw new AppError('Widget not found', ErrorCodes.WIDGET_NOT_FOUND, 404);
}
const payload = await buildWidgetPayload(widget);
const nextVersion = await getNextWidgetVersion(widget.id);
const version = await createWidgetVersion({
widgetId: widget.id,
version: nextVersion,
configSnapshot: normalizeWidgetConfig(widget.config as Record<string, unknown>),
payloadSnapshot: payload,
runtimeManifest: {
version: 'v1',
release: 'current',
entryUrl: getCourseWidgetScriptUrl()
},
publishedByUserId: userId
});
const updatedWidget = await updateWidget(orgId, widget.id, {
status: 'PUBLISHED',
hasUnpublishedChanges: false,
latestPublishedVersionId: version.id,
updatedByUserId: userId
});
return {
widget: updatedWidget,
version,
payload,
embedCode: getWidgetEmbedCode(widget.publicKey),
publicScriptUrl: getCourseWidgetScriptUrl()
};
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to publish widget',
ErrorCodes.WIDGET_PUBLISH_FAILED,
500
);
}
}
export async function rollbackOrganizationWidget(
orgId: string,
widgetId: string,
userId: string,
data: TRollbackWidget
) {
try {
const [widget, version] = await Promise.all([
getWidgetById(orgId, widgetId),
getWidgetVersionById(orgId, widgetId, data.versionId)
]);
if (!widget) {
throw new AppError('Widget not found', ErrorCodes.WIDGET_NOT_FOUND, 404);
}
if (!version) {
throw new AppError('Widget version not found', ErrorCodes.WIDGET_NOT_FOUND, 404);
}
const nextVersion = await getNextWidgetVersion(widgetId);
const rollbackVersion = await createWidgetVersion({
widgetId: widget.id,
version: nextVersion,
configSnapshot: version.configSnapshot,
payloadSnapshot: version.payloadSnapshot,
runtimeManifest: version.runtimeManifest,
rolledBackFromVersionId: version.id,
publishedByUserId: userId
});
const updatedWidget = await updateWidget(orgId, widgetId, {
status: 'PUBLISHED',
hasUnpublishedChanges: false,
latestPublishedVersionId: rollbackVersion.id,
updatedByUserId: userId
});
return {
widget: updatedWidget,
version: rollbackVersion
};
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to rollback widget',
ErrorCodes.WIDGET_ROLLBACK_FAILED,
500
);
}
}
export async function previewOrganizationWidget(orgId: string, widgetId: string, data: TPreviewWidget) {
try {
const widget = await getWidgetById(orgId, widgetId);
if (!widget) {
throw new AppError('Widget not found', ErrorCodes.WIDGET_NOT_FOUND, 404);
}
const overriddenWidget = {
...widget,
layoutType: data.layoutType ?? widget.layoutType,
selectionMode: data.selectionMode ?? widget.selectionMode,
config: data.config ?? widget.config
};
return buildWidgetPayload(overriddenWidget, data.selectedCourseIds);
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to build widget preview',
ErrorCodes.WIDGET_FETCH_FAILED,
500
);
}
}
export async function getPublishedWidgetPayload(publicKey: string) {
try {
const payload = await getPublishedWidgetPayloadByPublicKey(publicKey);
if (!payload) {
throw new AppError('Widget not found', ErrorCodes.WIDGET_NOT_FOUND, 404);
}
return ZWidgetPayload.parse(payload);
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to fetch widget payload',
ErrorCodes.WIDGET_PAYLOAD_FETCH_FAILED,
500
);
}
}
+1
View File
@@ -6,6 +6,7 @@ export type AuthSession = {
actorId: string | null;
automationKey: TOrganizationApiKey | null;
orgId: string | null;
orgRoles: Record<string, number>;
session: typeof auth.$Infer.Session.session | null;
user: typeof auth.$Infer.Session.user | null;
userRole: number | null;
+1
View File
@@ -0,0 +1 @@
declare module 'css-tree';
@@ -96,3 +96,39 @@ export const endpointKeyGenerator =
const baseKey = userKeyGenerator(c);
return `${baseKey}:${endpoint}`;
};
// ─── Agent Document Keys ─────────────────────────────────────────────────────
/**
* Redis key for storing extracted document text from AI assistant uploads.
* TTL: 3600 seconds (1 hour).
*/
export const agentDocumentKey = (documentId: string): string => {
return `agent:document:${documentId}`;
};
/**
* Rate limit key for agent chat endpoint (per-user).
*/
export const agentChatKeyGenerator = (c: Context): string => {
const baseKey = userKeyGenerator(c);
return `${baseKey}:agent:chat`;
};
// ─── Dashboard / analytics cache ─────────────────────────────────────────────
/**
* Redis key for `getStudentLoginActivity` (day-of-week chart per org and window).
* Value: JSON array `{ day, count }[]`. TTL: 24h.
*/
export function dashLoginActivityKey(orgId: string, days: number): string {
return `dash:login-activity:${orgId}:${days}`;
}
/**
* Rate limit key for agent upload endpoint (per-user).
*/
export const agentUploadKeyGenerator = (c: Context): string => {
const baseKey = userKeyGenerator(c);
return `${baseKey}:agent:upload`;
};
+40
View File
@@ -0,0 +1,40 @@
/**
* Tinybird event tracking for AI assistant observability.
* Fire-and-forget: never blocks the agent, silently skipped when not configured.
*/
const TINYBIRD_TOKEN = process.env.TINYBIRD_TOKEN;
const TINYBIRD_BASE_URL = process.env.TINYBIRD_BASE_URL || 'https://api.tinybird.co';
/**
* Send an event to Tinybird for analytics and debugging.
* Safe to call without awaiting — errors are swallowed.
*/
export function trackAgentEvent(eventName: string, data: Record<string, unknown>): void {
if (!TINYBIRD_TOKEN) return;
fetch(`${TINYBIRD_BASE_URL}/v0/events?name=${eventName}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${TINYBIRD_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ ...data, timestamp: new Date().toISOString() })
}).catch(() => {
// Intentionally swallowed — observability must never break the agent
});
}
// ─── Event Names ─────────────────────────────────────────────────────────────
export const AgentEvent = {
CHAT_STARTED: 'agent.chat.started',
CHAT_COMPLETED: 'agent.chat.completed',
CHAT_ERROR: 'agent.chat.error',
TOOL_CALLED: 'agent.tool.called',
TOOL_COMPLETED: 'agent.tool.completed',
TOOL_FAILED: 'agent.tool.failed',
DOCUMENT_UPLOADED: 'agent.document.uploaded',
PLAN_GENERATED: 'agent.plan.generated',
PLAN_EXECUTED: 'agent.plan.executed'
} as const;