feat: widgets and ai chat
This commit is contained in:
+13
-1
@@ -40,4 +40,16 @@ LICENSE_KEY=""
|
||||
|
||||
# SSO (Optional)
|
||||
# For enterprise SSO with SAML (future support)
|
||||
# SSO_ENCRYPTION_KEY="your-32-character-secret-key"
|
||||
# SSO_ENCRYPTION_KEY="your-32-character-secret-key"
|
||||
|
||||
# AI Assistant (Optional)
|
||||
# Per-provider API keys. The dashboard model picker exposes Gemini 3 Flash
|
||||
# (Google) and GPT-4o (OpenAI). Anthropic is supported in code but not in the
|
||||
# picker UI. Set at least one key to enable the assistant.
|
||||
OPENAI_API_KEY=""
|
||||
GOOGLE_API_KEY=""
|
||||
ANTHROPIC_API_KEY=""
|
||||
|
||||
# Tinybird event tracking for AI observability (optional — silently skipped when not set)
|
||||
# TINYBIRD_TOKEN=""
|
||||
# TINYBIRD_BASE_URL="https://api.tinybird.co"
|
||||
@@ -40,6 +40,7 @@
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.388.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.675.0",
|
||||
"@cio/ai-assistant": "workspace:*",
|
||||
"@cio/db": "workspace:*",
|
||||
"@cio/email": "workspace:*",
|
||||
"@cio/question-types": "workspace:*",
|
||||
@@ -49,6 +50,7 @@
|
||||
"@hono/zod-validator": "^0.7.6",
|
||||
"@scalar/hono-api-reference": "^0.9.18",
|
||||
"@sentry/profiling-node": "^7.110.1",
|
||||
"ai": "^6.0.158",
|
||||
"axios": "^1.4.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
@@ -56,9 +58,12 @@
|
||||
"hono-openapi": "^1.0.8",
|
||||
"hono-rate-limiter": "^0.4.2",
|
||||
"isomorphic-dompurify": "^3.7.1",
|
||||
"jszip": "^3.10.1",
|
||||
"katex": "^0.16.10",
|
||||
"mammoth": "^1.9.0",
|
||||
"marked": "^7.0.3",
|
||||
"nanoid": "^5.1.5",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"redis": "^4.7.0",
|
||||
"zod": "^4.1.8",
|
||||
"zod-openapi": "^5.4.1"
|
||||
@@ -66,6 +71,7 @@
|
||||
"devDependencies": {
|
||||
"@types/katex": "^0.16.7",
|
||||
"@types/node": "^20.19.0",
|
||||
"@types/pdf-parse": "^1.1.5",
|
||||
"@typescript-eslint/eslint-plugin": "7.0.1",
|
||||
"@typescript-eslint/parser": "7.0.1",
|
||||
"@vitest/coverage-v8": "^1.2.2",
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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';
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
export * from './widgets';
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(/ | /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
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Vendored
+1
@@ -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`;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -1,6 +1,12 @@
|
||||
import path from 'node:path';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@api': path.resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
|
||||
@@ -19,6 +19,8 @@ PUBLIC_IS_SELFHOSTED=false
|
||||
# CSP_FRAME_SRC_DOMAINS=
|
||||
# CSP_FONT_SRC_DOMAINS=
|
||||
# CSP_MEDIA_SRC_DOMAINS= (applies to both img-src and media-src)
|
||||
# CSP_FRAME_ANCESTORS_DOMAINS= (extra parents allowed to iframe this app; appended after 'self'. Overridden by ALLOWED_EXTERNAL_DOMAINS when set.)
|
||||
# Example: CSP_FRAME_ANCESTORS_DOMAINS=http://localhost:5180
|
||||
|
||||
# Google Drive video picker (lesson editor). OAuth Web client ID + Browser API key from Google Cloud Console (Picker API + Drive API enabled).
|
||||
# PUBLIC_GOOGLE_PICKER_CLIENT_ID=
|
||||
@@ -27,9 +29,6 @@ PUBLIC_IS_SELFHOSTED=false
|
||||
# Unsplash setup - Optional if you want to use Unsplash images
|
||||
UNSPLASH_API_KEY=
|
||||
|
||||
# OpenAI setup - Optional if you want to use the AI features
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# Sever URL - Optional if you want to use video upload and pdf downloads
|
||||
# This is the deployment URL of apps/api
|
||||
PUBLIC_SERVER_URL=
|
||||
@@ -41,21 +40,3 @@ PRIVATE_SERVER_URL=
|
||||
# The final domain is app.classroomio.com where `app` is PRIVATE_APP_SUBDOMAINS and `classroomio.com` is PRIVATE_APP_HOST
|
||||
PRIVATE_APP_HOST=classroomio.com
|
||||
PRIVATE_APP_SUBDOMAINS=app
|
||||
|
||||
|
||||
# ============ CLOUD VERSION SPECIFIC ============
|
||||
LEMON_SQUEEZY_API_KEY=
|
||||
LEMON_SQUEEZY_STORE_ID=
|
||||
LEMON_SQUEEZY_WEBHOOK_SECRET=
|
||||
|
||||
VITE_SENTRY_AUTH_TOKEN=
|
||||
VITE_SENTRY_ORG_NAME=
|
||||
VITE_SENTRY_PROJECT_NAME=
|
||||
|
||||
# Configure https locally https://stackoverflow.com/a/76525335
|
||||
USE_HTTPS_ON_LOCALHOST=false
|
||||
|
||||
## Custom Domain
|
||||
TEAM_ID_VERCEL=
|
||||
PROJECT_ID_VERCEL=
|
||||
AUTH_BEARER_TOKEN=
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
# Route Group Splitting — Optimization for Org Landing Pages
|
||||
|
||||
## Problem
|
||||
|
||||
When a user opens an organization via its subdomain (e.g. `myorg.classroomio.com`), the entire dashboard application was loaded before the landing page could render. This includes heavy dependencies like `UpgradeModal`, `PageLoadProgress`, `appInitApi`, `authClient`, and all theme components — none of which are needed to display a public landing page.
|
||||
|
||||
The result: slow initial load for org landing pages, which should be lightweight and fast.
|
||||
|
||||
## Solution: SvelteKit Route Groups
|
||||
|
||||
Instead of deploying a separate app (which conflicts with the single-domain self-hosted architecture), we split routes into **route groups** with separate layout chains. This leverages SvelteKit's built-in code splitting — each group only loads the JS it needs.
|
||||
|
||||
### Route Group Structure
|
||||
|
||||
```
|
||||
routes/
|
||||
├── +layout.svelte # Ultra-thin: global CSS, MetaTags, ModeWatcher, Snackbar
|
||||
├── +layout.server.ts # Root server layout: org detection (getOrgSiteInfo)
|
||||
├── +page.svelte # Root page: branches between org landing page and dashboard entry
|
||||
├── _dashboard-entry.svelte # Dynamically imported: auth + setupApp for non-org root visits
|
||||
│
|
||||
├── (org-site)/ # Public org pages — lightweight layout
|
||||
│ ├── +layout.svelte # Org store setup + optional auth (no login redirect)
|
||||
│ ├── +layout.ts # Passthrough load
|
||||
│ ├── courses/ # /courses — public course listing
|
||||
│ └── course/[slug]/ # /course/[slug] — individual course page + enrollment
|
||||
│
|
||||
├── (app)/ # Authenticated dashboard — full layout
|
||||
│ ├── +layout.svelte # Auth session, appInitApi.setupApp(), UpgradeModal, etc.
|
||||
│ ├── +layout.ts # Passthrough load
|
||||
│ ├── courses/ # Course management
|
||||
│ ├── org/ # Org settings
|
||||
│ ├── lms/ # Student LMS
|
||||
│ ├── programs/ # Programs
|
||||
│ └── home/ # Dashboard home
|
||||
│
|
||||
├── (auth)/ # Auth pages (login, signup, etc.)
|
||||
├── invite/ # Invite handling
|
||||
└── api/ # API routes
|
||||
```
|
||||
|
||||
## Key Architecture Decisions
|
||||
|
||||
### 1. Root Layout is Ultra-Thin
|
||||
|
||||
`+layout.svelte` only contains:
|
||||
|
||||
- Global CSS import
|
||||
- `MetaTags` (with merge from page data)
|
||||
- `ModeWatcher` (dark mode)
|
||||
- `Snackbar`
|
||||
|
||||
No auth logic, no org-specific logic, no heavy component imports. This ensures the shared root bundle is minimal for all routes.
|
||||
|
||||
### 2. Root Page (`+page.svelte`) Branches by Context
|
||||
|
||||
The `/` route handles two completely different experiences:
|
||||
|
||||
- **Org site** (`data.isOrgSite === true`): SSR-fetches public courses in `+page.server.ts`, then dynamically imports the theme component via `importThemeComponent()`. Sets up org stores (`currentOrg`, `globalStore`).
|
||||
- **Non-org site** (`data.isOrgSite === false`): Dynamically imports `_dashboard-entry.svelte`, which handles auth session checking and `appInitApi.setupApp()` for redirect logic.
|
||||
|
||||
The dynamic imports are critical — they prevent the dashboard's auth dependencies from being bundled with the org landing page, and vice versa.
|
||||
|
||||
### 3. `(org-site)` Layout — Lightweight with Optional Auth
|
||||
|
||||
`(org-site)/+layout.svelte` responsibilities:
|
||||
|
||||
- Set up org stores from server data (`currentOrg`, `globalStore.isOrgSite`, theme)
|
||||
- Set user store from `data.locals` if available
|
||||
- Check auth session via `authClient.useSession()`
|
||||
- When session is ready, call `appInitApi.setupApp()` to load full account details
|
||||
- **Does NOT redirect to `/login`** — these pages are public
|
||||
|
||||
The auth handling is needed so that logged-in users see the correct state (e.g. "Go to LMS" vs "Login" button in the nav). `setupApp` loads the user's organizations and roles, which populates `isOrgStudent` and `basePath`.
|
||||
|
||||
`routeUserToNextPage` inside `setupApp` already handles org-site public routes — it skips redirects when `isPublicRoute(path) && (path !== '/' || isOrgSite)`.
|
||||
|
||||
### 4. `(app)` Layout — Full Auth + App Initialization
|
||||
|
||||
`(app)/+layout.svelte` responsibilities:
|
||||
|
||||
- `authClient.useSession()` with redirect to `/login` for unauthenticated users
|
||||
- `appInitApi.setupApp()` for full account loading
|
||||
- `UpgradeModal`, `PageLoadProgress`, `PageRestricted`
|
||||
- Org store setup from server data
|
||||
|
||||
This layout carries all the heavy dashboard dependencies, which are only loaded for authenticated routes.
|
||||
|
||||
### 5. Dynamic Theme Imports
|
||||
|
||||
Previously, all three landing page themes (bold, classic, minimal) were statically imported, meaning all theme code was bundled even when only one was used.
|
||||
|
||||
Now `importThemeComponent()` in `$features/org/utils/landing-page.ts` uses dynamic imports:
|
||||
|
||||
```typescript
|
||||
export function importThemeComponent(theme: LandingPageThemeKey) {
|
||||
switch (theme) {
|
||||
case 'bold':
|
||||
return import('@cio/ui/custom/org-landing-page/bold.svelte');
|
||||
case 'classic':
|
||||
return import('@cio/ui/custom/org-landing-page/classic.svelte');
|
||||
default:
|
||||
return import('@cio/ui/custom/org-landing-page/minimal.svelte');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Static imports of all themes are preserved in `$features/org/utils/landing-page-components.ts` — this file is only imported by the dashboard's landing page editor where all themes are needed for preview.
|
||||
|
||||
### 6. SSR for Public Course Data
|
||||
|
||||
`+page.server.ts` fetches public courses server-side for org sites, so the landing page renders with data immediately instead of showing a loading state while fetching client-side.
|
||||
|
||||
## Store Refactoring: `isOrgStudent`
|
||||
|
||||
### Before
|
||||
|
||||
- `isOrgStudent` lived in `$lib/utils/store/org.ts`
|
||||
- `globalStore` had an `isStudent` field
|
||||
- Three separate `$effect` blocks in `+page.svelte`, `(org-site)/+layout.svelte`, and `(app)/+layout.svelte` manually synced `isOrgStudent` → `globalStore.isStudent`
|
||||
- `isStudentExperience` derived from `globalStore.isStudent`
|
||||
|
||||
This was fragile — if any sync `$effect` was removed (as happened during refactoring), `isStudentExperience` and `basePath` would break.
|
||||
|
||||
### After
|
||||
|
||||
- `isOrgStudent` moved to `$lib/utils/store/app.ts`, derived directly from `currentOrg.roleId`
|
||||
- `globalStore.isStudent` removed entirely
|
||||
- `isStudentExperience` now derives from `[globalStore, isOrgStudent]` directly — no manual sync needed
|
||||
- All `$effect` sync blocks removed from layouts and root page
|
||||
- All component reads of `$globalStore.isStudent` replaced with `$isOrgStudent`
|
||||
|
||||
One source of truth, no manual sync, no fragile `$effect` chains.
|
||||
|
||||
## Auth Flow — Centralized in Root Layout
|
||||
|
||||
Auth session checking and `appInitApi.setupApp()` live in the **root layout** (`+layout.svelte`). This is the single solution that handles auth for all routes — no per-group duplication, no route audits.
|
||||
|
||||
### Why centralized?
|
||||
|
||||
An earlier iteration put auth in each group layout (`(app)`, `(org-site)`, `_dashboard-entry`). This broke when new route groups (like `(auth)`) didn't have auth handling — authenticated users on `/login` or `/signup` would see infinite spinners instead of being redirected. Centralizing eliminates that class of bugs entirely.
|
||||
|
||||
### What the root layout does
|
||||
|
||||
1. `authClient.useSession()` — checks for an active session on every route
|
||||
2. When session is ready, calls `appInitApi.setupApp()` — loads full account (profile, orgs, roles)
|
||||
3. `setupApp` → `routeUserToNextPage()` handles redirects based on context:
|
||||
- On `/login`, `/signup`, `/onboarding`, or `/`: redirects to `/lms` or `/org/[slug]` based on role
|
||||
- On public org-site routes (`/courses`, `/course/[slug]`): skips redirect (user stays on page with correct login state)
|
||||
- On other public routes: skips redirect
|
||||
4. Sets up user store, org stores, and theme from server data
|
||||
5. No redirect to `/login` — that's the `(app)` layout's job (protected routes only)
|
||||
|
||||
### What each group layout does
|
||||
|
||||
- **`(app)/+layout.svelte`**: Redirects to `/login` if unauthenticated (protected routes). Renders `UpgradeModal`, `PageLoadProgress`, `PageRestricted`.
|
||||
- **`(org-site)/+layout.svelte`**: `PageRestricted` gating only. No auth logic — root handles it.
|
||||
- **`(auth)` group**: No layout file needed. Root layout handles session → `setupApp` → redirect away if already authenticated.
|
||||
|
||||
### Flow: login on org site
|
||||
|
||||
1. User logs in at `/login` → redirected to `/`
|
||||
2. Root layout detects session, calls `setupApp` → loads account details
|
||||
3. `routeUserToNextPage` sees `isPublicRoute('/') && isOrgSite` → skips redirect
|
||||
4. User sees landing page with correct login state ("Go to LMS" button)
|
||||
|
||||
### Flow: login on non-org site
|
||||
|
||||
1. User visits `/` → root layout calls `setupApp`
|
||||
2. `routeUserToNextPage` redirects to `/lms`, `/org/[slug]`, or `/onboarding` based on role
|
||||
3. `+page.svelte` shows spinner while redirect happens
|
||||
|
||||
### Flow: authenticated user hits /login
|
||||
|
||||
1. Root layout detects session, calls `setupApp`
|
||||
2. `routeUserToNextPage` sees `shouldRedirectOnAuth('/login')` → true
|
||||
3. Redirects to `/lms` or `/org/[slug]`
|
||||
|
||||
## Files Changed
|
||||
|
||||
### Store layer
|
||||
|
||||
- `apps/dashboard/src/lib/utils/store/app.ts` — added `isOrgStudent`, removed `globalStore.isStudent`, rewrote `isStudentExperience`
|
||||
- `apps/dashboard/src/lib/utils/store/org.ts` — removed `isOrgStudent` export
|
||||
- `apps/dashboard/src/lib/features/app/init.svelte.ts` — updated `isOrgStudent` import path
|
||||
|
||||
### Layout layer
|
||||
|
||||
- `apps/dashboard/src/routes/+layout.svelte` — stripped to minimal
|
||||
- `apps/dashboard/src/routes/+page.svelte` — branching logic for org vs non-org
|
||||
- `apps/dashboard/src/routes/_dashboard-entry.svelte` — new, handles non-org auth
|
||||
- `apps/dashboard/src/routes/(org-site)/+layout.svelte` — org store setup + optional auth
|
||||
- `apps/dashboard/src/routes/(app)/+layout.svelte` — full auth + app init
|
||||
|
||||
### Component layer (isOrgStudent migration)
|
||||
|
||||
- `features/course/pages/exercise.svelte`
|
||||
- `features/course/pages/lesson.svelte`
|
||||
- `features/course/pages/attendance.svelte`
|
||||
- `features/course/components/exercise/view-mode.svelte`
|
||||
- `features/course/components/lesson/content-list.svelte`
|
||||
- `features/course/components/lesson/content-navigation-actions.svelte`
|
||||
- `features/course/components/lesson/content-section-list.svelte`
|
||||
- `features/course/components/lesson/lesson-page-edit-header.svelte`
|
||||
- `features/course/components/sidebar/course-sidebar.svelte`
|
||||
- `routes/(app)/courses/[id]/exercises/[exerciseId]/+page.svelte`
|
||||
|
||||
### Landing page optimization
|
||||
|
||||
- `features/org/utils/landing-page.ts` — dynamic `importThemeComponent()`
|
||||
- `features/org/utils/landing-page-components.ts` — new, static imports for editor only
|
||||
- `routes/+page.server.ts` — SSR course data for org sites
|
||||
|
||||
## Remaining Work / Known Issues
|
||||
|
||||
- **Compliance utils (`isSelfPacedLikeCourse`)**: Several components (`exercise.svelte`, `view-mode.svelte`, `content-list.svelte`, `content-section-list.svelte`) had `isSelfPacedLikeCourse` from `compliance-utils.ts` mixed into the `isOrgStudent` commit. These have been reverted to inline `=== 'SELF_PACED'` checks and should be re-introduced in a dedicated compliance commit.
|
||||
- **`$inspect` and `console.log` cleanup**: `(app)/+layout.svelte` contains `$inspect('session', $session)` and env-logging `console.log` calls that should be removed before production.
|
||||
- **Bundle size verification**: Run `pnpm --filter @cio/dashboard build` and compare chunk sizes for org-site vs app routes to confirm the code splitting is effective.
|
||||
- **`(org-site)` course pages**: `courses/+page.svelte` and `course/[slug]/+page.svelte` still statically import nav/hero components from `@cio/ui/custom/org-landing-page`. These could be dynamically imported for further optimization.
|
||||
@@ -19,6 +19,7 @@
|
||||
"translate": "node ./scripts/translate.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/svelte": "^4.0.158",
|
||||
"@better-auth/sso": "1.4.18",
|
||||
"@cio/api": "workspace:*",
|
||||
"@cio/db": "workspace:*",
|
||||
@@ -35,7 +36,7 @@
|
||||
"@tailwindcss/forms": "^0.5.4",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@types/pluralize": "0.0.30",
|
||||
"ai": "^2.1.31",
|
||||
"ai": "^6.0.158",
|
||||
"axios": "^1.8.4",
|
||||
"better-auth": "^1.4.18",
|
||||
"bits-ui": "^2.14.4",
|
||||
@@ -58,6 +59,7 @@
|
||||
"jspdf-autotable": "^3.8.2",
|
||||
"ky": "^1.10.0",
|
||||
"lodash": "^4.17.21",
|
||||
"marked": "^7.0.5",
|
||||
"openai-edge": "^1.2.2",
|
||||
"papaparse": "^5.4.1",
|
||||
"pluralize": "^8.0.0",
|
||||
|
||||
@@ -0,0 +1,586 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import ChatHeader from '$features/ai-assistant/chat-header.svelte';
|
||||
import ChatMessageList from '$features/ai-assistant/chat-message-list.svelte';
|
||||
import ChatInput from '$features/ai-assistant/chat-input.svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { getToolLabel, getToolResultLabel, MUTATION_TOOLS } from '$features/ai-assistant/utils/tool-labels';
|
||||
import type { ProgressStep } from '$features/ai-assistant/utils/tool-labels';
|
||||
import {
|
||||
getAgentToolName,
|
||||
getAgentToolResult,
|
||||
getAgentToolStatus,
|
||||
isAgentToolPart,
|
||||
type AgentToolPart
|
||||
} from '$features/ai-assistant/utils/tool-parts';
|
||||
import { isAiAssistantOpen } from '$features/ai-assistant/utils/store';
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Chat } from '@ai-sdk/svelte';
|
||||
import { DefaultChatTransport } from 'ai';
|
||||
import { courseApi, lessonApi } from '$features/course/api';
|
||||
import { getMentionableContent } from '$features/course/utils/content';
|
||||
import { refreshExercisePageData } from '$features/course/utils/exercise-page-utils';
|
||||
import { getRequestBaseUrl, apiClient } from '$lib/utils/services/api';
|
||||
import { startResizablePanelDrag } from '$lib/utils/functions/resizable-panel';
|
||||
import { aiAssistantApi } from '$features/ai-assistant/api/ai-assistant.svelte';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import type { AiAssistantMessage, UploadedDocument } from '$features/ai-assistant/utils/types';
|
||||
import {
|
||||
AI_CHAT_MODEL_STORAGE_KEY,
|
||||
AI_COURSE_CHAT_DEFAULT_WIDTH,
|
||||
AI_COURSE_CHAT_MAX_WIDTH,
|
||||
AI_COURSE_CHAT_MIN_WIDTH,
|
||||
AI_COURSE_CHAT_STORAGE_KEY
|
||||
} from '$features/ai-assistant/utils/constants';
|
||||
import { AGENT_MODEL_IDS, DEFAULT_PICKER_MODEL_ID, type AgentModelId } from '@cio/utils/agent-models';
|
||||
|
||||
// Extract current lessonId/exerciseId from route params
|
||||
const currentLessonId = $derived(page.params?.lessonId as string | undefined);
|
||||
const currentExerciseId = $derived(page.params?.exerciseId as string | undefined);
|
||||
|
||||
let inputValue = $state('');
|
||||
let uploadedDocument: UploadedDocument | null = $state(null);
|
||||
let isUploading = $state(false);
|
||||
|
||||
let usageFetched = $state(false);
|
||||
let conversationsLoaded = $state(false);
|
||||
let activeConversationId: string | null = $state(null);
|
||||
let chatWidth = $state(AI_COURSE_CHAT_DEFAULT_WIDTH);
|
||||
let hasLoadedChatWidth = $state(false);
|
||||
let isChatResizing = $state(false);
|
||||
let stopChatResize: (() => void) | null = null;
|
||||
let chatShellElement: HTMLDivElement | null = null;
|
||||
let selectedModel: AgentModelId = $state(DEFAULT_PICKER_MODEL_ID);
|
||||
|
||||
function isAgentModelId(value: unknown): value is AgentModelId {
|
||||
return typeof value === 'string' && (AGENT_MODEL_IDS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function handleSelectModel(id: AgentModelId) {
|
||||
selectedModel = id;
|
||||
|
||||
try {
|
||||
localStorage.setItem(AI_CHAT_MODEL_STORAGE_KEY, id);
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
const tokenUsage = $derived(aiAssistantApi.status?.usage ?? null);
|
||||
|
||||
function clampChatWidth(width: number) {
|
||||
return Math.min(AI_COURSE_CHAT_MAX_WIDTH, Math.max(AI_COURSE_CHAT_MIN_WIDTH, width));
|
||||
}
|
||||
|
||||
function clearChatResizeListeners() {
|
||||
stopChatResize?.();
|
||||
stopChatResize = null;
|
||||
}
|
||||
|
||||
function getStorageKey(courseId: string) {
|
||||
return `ai-chat-active-${courseId}`;
|
||||
}
|
||||
|
||||
function getActiveConversationId(courseId: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(getStorageKey(courseId));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setActiveConversationId(courseId: string, conversationId: string | null) {
|
||||
activeConversationId = conversationId;
|
||||
|
||||
try {
|
||||
if (conversationId) {
|
||||
localStorage.setItem(getStorageKey(courseId), conversationId);
|
||||
} else {
|
||||
localStorage.removeItem(getStorageKey(courseId));
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConversation(conversationId: string) {
|
||||
const courseId = courseApi.course?.id;
|
||||
|
||||
if (!courseId) return;
|
||||
|
||||
await aiAssistantApi.loadConversation(conversationId);
|
||||
|
||||
const conversation = aiAssistantApi.currentConversation;
|
||||
|
||||
if (conversation) {
|
||||
chat.messages = (conversation.messages ?? []) as AiAssistantMessage[];
|
||||
setActiveConversationId(courseId, conversationId);
|
||||
}
|
||||
}
|
||||
|
||||
async function startNewChat() {
|
||||
const courseId = courseApi.course?.id;
|
||||
|
||||
if (!courseId) return;
|
||||
|
||||
const created = await aiAssistantApi.createConversation(courseId);
|
||||
|
||||
if (created) {
|
||||
chat.messages = [];
|
||||
setActiveConversationId(courseId, created.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteConversation(conversationId: string) {
|
||||
const courseId = courseApi.course?.id;
|
||||
|
||||
if (!courseId) return;
|
||||
|
||||
await aiAssistantApi.deleteConversation(conversationId);
|
||||
|
||||
if (activeConversationId === conversationId) {
|
||||
chat.messages = [];
|
||||
setActiveConversationId(courseId, null);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch status and load conversations when panel opens
|
||||
$effect(() => {
|
||||
const courseId = courseApi.course?.id;
|
||||
|
||||
if ($isAiAssistantOpen && !usageFetched && courseId) {
|
||||
usageFetched = true;
|
||||
aiAssistantApi.fetchStatus(courseId);
|
||||
}
|
||||
|
||||
if ($isAiAssistantOpen && !conversationsLoaded && courseId) {
|
||||
conversationsLoaded = true;
|
||||
|
||||
const savedId = getActiveConversationId(courseId);
|
||||
|
||||
aiAssistantApi.listConversations(courseId).then(() => {
|
||||
if (savedId) {
|
||||
loadConversation(savedId);
|
||||
} else if (aiAssistantApi.conversations.length > 0) {
|
||||
loadConversation(aiAssistantApi.conversations[0].id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!$isAiAssistantOpen) {
|
||||
usageFetched = false;
|
||||
conversationsLoaded = false;
|
||||
}
|
||||
});
|
||||
|
||||
const chat = new Chat({
|
||||
id: 'ai-assistant',
|
||||
transport: new DefaultChatTransport({
|
||||
api: `${getRequestBaseUrl()}/agent/chat`,
|
||||
credentials: 'include',
|
||||
body: () => ({
|
||||
courseId: courseApi.course?.id ?? '',
|
||||
model: selectedModel,
|
||||
context: {
|
||||
lessonId: page.params?.lessonId,
|
||||
exerciseId: page.params?.exerciseId,
|
||||
documentId: uploadedDocument?.id
|
||||
}
|
||||
}),
|
||||
fetch: (input, init) => apiClient.request(input, init)
|
||||
}),
|
||||
onFinish: async () => {
|
||||
const courseId = courseApi.course?.id;
|
||||
const profileId = $profile.id;
|
||||
|
||||
// Force refetch course data so new sections/lessons/exercises show in the UI
|
||||
if (courseId && profileId) {
|
||||
courseApi.refreshCourse(courseId, profileId);
|
||||
}
|
||||
|
||||
// Refresh current lesson content if viewing a lesson
|
||||
if (courseId && currentLessonId) {
|
||||
lessonApi.get(courseId, currentLessonId);
|
||||
}
|
||||
|
||||
// Refresh current exercise if viewing an exercise
|
||||
if (courseId && currentExerciseId) {
|
||||
await refreshExercisePageData(courseId, currentExerciseId);
|
||||
}
|
||||
|
||||
// Refresh usage meter
|
||||
if (courseId) {
|
||||
aiAssistantApi.fetchStatus(courseId);
|
||||
}
|
||||
|
||||
// Clear document attachment after message is processed
|
||||
uploadedDocument = null;
|
||||
|
||||
// Persist messages to the active conversation
|
||||
if (activeConversationId) {
|
||||
await aiAssistantApi.saveMessages(activeConversationId, chat.messages as AiAssistantMessage[]);
|
||||
|
||||
// Generate a smart title after the first exchange (when title is still default)
|
||||
const activeConv = aiAssistantApi.conversations.find((c) => c.id === activeConversationId);
|
||||
const isDefaultTitle = !activeConv?.title || activeConv.title === 'New conversation';
|
||||
const firstUserMsg = chat.messages.find((m) => m.role === 'user');
|
||||
const textPart = firstUserMsg?.parts?.find((p): p is Extract<typeof p, { type: 'text' }> => p.type === 'text');
|
||||
|
||||
if (isDefaultTitle && textPart?.text) {
|
||||
await aiAssistantApi.generateTitle(activeConversationId, textPart.text.slice(0, 500));
|
||||
}
|
||||
} else if (courseId) {
|
||||
// No active conversation yet — create one and save
|
||||
const created = await aiAssistantApi.createConversation(courseId);
|
||||
|
||||
if (created) {
|
||||
setActiveConversationId(courseId, created.id);
|
||||
await aiAssistantApi.saveMessages(created.id, chat.messages as AiAssistantMessage[]);
|
||||
|
||||
const firstUserMsg = chat.messages.find((m) => m.role === 'user');
|
||||
const textPart = firstUserMsg?.parts?.find(
|
||||
(p): p is Extract<typeof p, { type: 'text' }> => p.type === 'text'
|
||||
);
|
||||
|
||||
if (textPart?.text) {
|
||||
await aiAssistantApi.generateTitle(created.id, textPart.text.slice(0, 500));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const quickActions = [
|
||||
'Upload a document to create a course',
|
||||
'Draft a lesson',
|
||||
'Generate questions from this lesson',
|
||||
"Improve this lesson's text",
|
||||
'Summarize this lesson'
|
||||
];
|
||||
|
||||
function handleSend() {
|
||||
if (!inputValue.trim() || chat.status === 'streaming') return;
|
||||
|
||||
const text = inputValue;
|
||||
const messageAttachment = uploadedDocument
|
||||
? {
|
||||
documentId: uploadedDocument.id,
|
||||
name: uploadedDocument.name
|
||||
}
|
||||
: undefined;
|
||||
|
||||
inputValue = '';
|
||||
|
||||
// Create conversation eagerly before sending the first message
|
||||
if (!activeConversationId) {
|
||||
const courseId = courseApi.course?.id;
|
||||
|
||||
if (!courseId) return;
|
||||
|
||||
aiAssistantApi.createConversation(courseId).then((created) => {
|
||||
if (created) {
|
||||
setActiveConversationId(courseId, created.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
chat.sendMessage({
|
||||
text,
|
||||
metadata: messageAttachment ? { attachment: messageAttachment } : undefined
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureActiveConversation(courseId: string): Promise<string | null> {
|
||||
if (activeConversationId) return activeConversationId;
|
||||
|
||||
const created = await aiAssistantApi.createConversation(courseId);
|
||||
|
||||
if (!created) return null;
|
||||
|
||||
setActiveConversationId(courseId, created.id);
|
||||
|
||||
return created.id;
|
||||
}
|
||||
|
||||
async function handleFileSelect(file: File) {
|
||||
const courseId = courseApi.course?.id;
|
||||
|
||||
if (!courseId) return;
|
||||
|
||||
const conversationId = await ensureActiveConversation(courseId);
|
||||
|
||||
if (!conversationId) return;
|
||||
|
||||
isUploading = true;
|
||||
const result = await aiAssistantApi.uploadDocument(file, courseId, conversationId);
|
||||
isUploading = false;
|
||||
|
||||
if (result) {
|
||||
uploadedDocument = { id: result.documentId, name: result.fileName };
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemoveDocument() {
|
||||
uploadedDocument = null;
|
||||
}
|
||||
|
||||
function handleQuickAction(action: string) {
|
||||
inputValue = action;
|
||||
handleSend();
|
||||
}
|
||||
|
||||
function handleStop() {
|
||||
chat.stop();
|
||||
}
|
||||
|
||||
function handleImplementPlan(editedPlan: unknown) {
|
||||
if (chat.status === 'streaming') return;
|
||||
|
||||
inputValue = '';
|
||||
|
||||
const courseId = courseApi.course?.id;
|
||||
|
||||
if (!activeConversationId) {
|
||||
if (!courseId) return;
|
||||
|
||||
aiAssistantApi.createConversation(courseId).then((created) => {
|
||||
if (created) {
|
||||
setActiveConversationId(courseId, created.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
chat.sendMessage({
|
||||
text: 'Implement this plan.',
|
||||
metadata: {
|
||||
plan: {
|
||||
action: 'implement_course_plan',
|
||||
payload: editedPlan
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleAskPlanChanges() {
|
||||
inputValue = '';
|
||||
}
|
||||
|
||||
function handleResume() {
|
||||
inputValue = 'Continue implementing the plan from where you left off.';
|
||||
handleSend();
|
||||
}
|
||||
|
||||
function handleMentionClick(route: string) {
|
||||
goto(resolve(route, {}));
|
||||
}
|
||||
|
||||
const isStreaming = $derived(chat.status === 'streaming' || chat.status === 'submitted');
|
||||
const isExhausted = $derived(tokenUsage !== null && tokenUsage.remaining <= 0);
|
||||
|
||||
const mentionItems = $derived(
|
||||
getMentionableContent(courseApi.course).map((item) => ({
|
||||
id: item.id,
|
||||
label: item.title,
|
||||
type: item.type
|
||||
}))
|
||||
);
|
||||
|
||||
// Show an activity card whenever the agent calls any tool.
|
||||
// Hides automatically once the agent finishes cleanly; stays visible if stopped mid-way.
|
||||
const planExecutionState = $derived.by(() => {
|
||||
const lastAssistantMsg = [...chat.messages].reverse().find((m) => m.role === 'assistant');
|
||||
|
||||
if (!lastAssistantMsg) return null;
|
||||
|
||||
const allToolParts = lastAssistantMsg.parts.filter((part: Record<string, unknown>) =>
|
||||
isAgentToolPart(part)
|
||||
) as AgentToolPart[];
|
||||
|
||||
// generate_course_plan renders its own PlanView — exclude it from the card
|
||||
const toolParts = allToolParts.filter((part) => getAgentToolName(part) !== 'generate_course_plan');
|
||||
|
||||
if (toolParts.length === 0) return null;
|
||||
|
||||
const steps: ProgressStep[] = toolParts.flatMap((part) => {
|
||||
const toolName = getAgentToolName(part);
|
||||
|
||||
if (!toolName) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result = getAgentToolResult(part) as Record<string, unknown> | undefined;
|
||||
const status = getAgentToolStatus(part);
|
||||
const label = status === 'completed' ? getToolResultLabel(toolName, result) : getToolLabel(toolName);
|
||||
|
||||
return [{ label, status }];
|
||||
});
|
||||
|
||||
const allDone = steps.every((s) => s.status === 'completed');
|
||||
const isStopped = !isStreaming && !allDone;
|
||||
|
||||
// Hide the card once the agent finishes cleanly — the text response takes over
|
||||
if (allDone && !isStreaming) return null;
|
||||
|
||||
const hasMutations = toolParts.some((part) => {
|
||||
const toolName = getAgentToolName(part);
|
||||
return toolName ? MUTATION_TOOLS.includes(toolName) : false;
|
||||
});
|
||||
const title = hasMutations ? 'Applying changes' : 'Working';
|
||||
const currentAction = steps.find((s) => s.status === 'in_progress')?.label;
|
||||
|
||||
return { steps, currentAction, isStopped, title };
|
||||
});
|
||||
|
||||
const conversationTitle = $derived(
|
||||
aiAssistantApi.conversations.find((c) => c.id === activeConversationId)?.title ?? null
|
||||
);
|
||||
|
||||
function handleChatResizePointerDown(event: PointerEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
clearChatResizeListeners();
|
||||
|
||||
const startWidth = chatWidth;
|
||||
|
||||
stopChatResize = startResizablePanelDrag({
|
||||
event,
|
||||
startWidth,
|
||||
resolveWidth: ({ startWidth, deltaX }) => clampChatWidth(startWidth - deltaX),
|
||||
onPreview: (width) => {
|
||||
chatShellElement?.style.setProperty('--ai-chat-width', `${width}px`);
|
||||
},
|
||||
onCommit: ({ width }) => {
|
||||
chatWidth = width;
|
||||
},
|
||||
onDragStart: () => {
|
||||
isChatResizing = true;
|
||||
},
|
||||
onDragEnd: () => {
|
||||
isChatResizing = false;
|
||||
stopChatResize = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
try {
|
||||
const storedWidth = Number(localStorage.getItem(AI_COURSE_CHAT_STORAGE_KEY));
|
||||
|
||||
if (Number.isFinite(storedWidth)) {
|
||||
chatWidth = clampChatWidth(storedWidth);
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
|
||||
try {
|
||||
const storedModel = localStorage.getItem(AI_CHAT_MODEL_STORAGE_KEY);
|
||||
|
||||
if (isAgentModelId(storedModel)) {
|
||||
selectedModel = storedModel;
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
|
||||
hasLoadedChatWidth = true;
|
||||
|
||||
return () => {
|
||||
clearChatResizeListeners();
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
clearChatResizeListeners();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!hasLoadedChatWidth) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(AI_COURSE_CHAT_STORAGE_KEY, String(Math.round(chatWidth)));
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet chatPanelContent()}
|
||||
<ChatHeader
|
||||
{tokenUsage}
|
||||
{conversationTitle}
|
||||
conversations={aiAssistantApi.conversations}
|
||||
{activeConversationId}
|
||||
isNewChatDisabled={chat.messages.length === 0}
|
||||
onNewChat={startNewChat}
|
||||
onLoadConversation={loadConversation}
|
||||
onDeleteConversation={handleDeleteConversation}
|
||||
/>
|
||||
|
||||
<ChatMessageList
|
||||
messages={chat.messages}
|
||||
status={chat.status}
|
||||
{isStreaming}
|
||||
courseId={courseApi.course?.id ?? ''}
|
||||
{planExecutionState}
|
||||
{quickActions}
|
||||
onQuickAction={handleQuickAction}
|
||||
onImplementPlan={handleImplementPlan}
|
||||
onAskPlanChanges={handleAskPlanChanges}
|
||||
onStop={handleStop}
|
||||
onResume={handleResume}
|
||||
onMentionClick={handleMentionClick}
|
||||
/>
|
||||
|
||||
<ChatInput
|
||||
bind:inputValue
|
||||
{isStreaming}
|
||||
{isExhausted}
|
||||
{isUploading}
|
||||
{uploadedDocument}
|
||||
{mentionItems}
|
||||
{selectedModel}
|
||||
error={chat.error}
|
||||
onSend={handleSend}
|
||||
onStop={handleStop}
|
||||
onFileSelect={handleFileSelect}
|
||||
onRemoveDocument={handleRemoveDocument}
|
||||
onSelectModel={handleSelectModel}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#if $isAiAssistantOpen}
|
||||
<div
|
||||
bind:this={chatShellElement}
|
||||
data-ai-chat-resizing={isChatResizing}
|
||||
class="contents"
|
||||
style={`--ai-chat-width: ${chatWidth}px;`}
|
||||
>
|
||||
<div class="hidden shrink-0 md:block md:w-(--ai-chat-width)"></div>
|
||||
|
||||
<div
|
||||
class="ui:bg-background fixed inset-y-0 right-0 z-100 flex h-screen w-full flex-col border-l md:w-(--ai-chat-width)"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Resize AI assistant panel"
|
||||
class="absolute inset-y-0 left-0 hidden w-4 -translate-x-1/2 cursor-col-resize border-0 bg-transparent md:flex"
|
||||
onpointerdown={handleChatResizePointerDown}
|
||||
>
|
||||
<span class="ui:bg-border pointer-events-none h-full w-px"></span>
|
||||
</button>
|
||||
|
||||
{@render chatPanelContent()}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
:global([data-ai-chat-resizing='true'] *) {
|
||||
transition-property: none !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,171 @@
|
||||
import { BaseApiWithErrors, classroomio, apiClient, getRequestBaseUrl } from '$lib/utils/services/api';
|
||||
import type {
|
||||
AgentConversation,
|
||||
AgentConversationCreateData,
|
||||
AgentConversationSummary,
|
||||
AgentStatusData,
|
||||
AiAssistantMessage
|
||||
} from '../utils/types';
|
||||
|
||||
class AiAssistantApi extends BaseApiWithErrors {
|
||||
status: AgentStatusData | null = $state(null);
|
||||
conversations: AgentConversationSummary[] = $state([]);
|
||||
currentConversation: AgentConversation | null = $state(null);
|
||||
|
||||
async fetchStatus(courseId: string) {
|
||||
await this.execute<typeof classroomio.agent.status.$get>({
|
||||
requestFn: () =>
|
||||
classroomio.agent.status.$get({
|
||||
query: { courseId }
|
||||
}),
|
||||
logContext: 'fetching agent status',
|
||||
onSuccess: (result) => {
|
||||
this.status = result.data;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async listConversations(courseId: string) {
|
||||
await this.execute<typeof classroomio.agent.history.$get>({
|
||||
requestFn: () =>
|
||||
classroomio.agent.history.$get({
|
||||
query: { courseId }
|
||||
}),
|
||||
logContext: 'listing conversations',
|
||||
onSuccess: (result) => {
|
||||
this.conversations = result.data as AgentConversationSummary[];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadConversation(conversationId: string) {
|
||||
await this.execute<(typeof classroomio.agent.history)[':conversationId']['$get']>({
|
||||
requestFn: () =>
|
||||
classroomio.agent.history[':conversationId'].$get({
|
||||
param: { conversationId }
|
||||
}),
|
||||
logContext: 'loading conversation',
|
||||
onSuccess: (result) => {
|
||||
this.currentConversation = {
|
||||
...(result.data as AgentConversation),
|
||||
messages: ((result.data as AgentConversation).messages ?? []) as AiAssistantMessage[]
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async createConversation(courseId: string, title?: string): Promise<{ id: string } | null> {
|
||||
let created: { id: string } | null = null;
|
||||
|
||||
await this.execute<typeof classroomio.agent.history.$post>({
|
||||
requestFn: () =>
|
||||
classroomio.agent.history.$post({
|
||||
json: { courseId, title }
|
||||
}),
|
||||
logContext: 'creating conversation',
|
||||
onSuccess: (result) => {
|
||||
const newConversation = result.data as AgentConversationCreateData;
|
||||
created = newConversation;
|
||||
|
||||
this.conversations = [
|
||||
{ ...newConversation, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
||||
...this.conversations
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async saveMessages(conversationId: string, messages: AiAssistantMessage[], title?: string) {
|
||||
await this.execute<(typeof classroomio.agent.history)[':conversationId']['$put']>({
|
||||
requestFn: () =>
|
||||
classroomio.agent.history[':conversationId'].$put({
|
||||
param: { conversationId },
|
||||
json: { messages, title }
|
||||
}),
|
||||
logContext: 'saving messages'
|
||||
});
|
||||
}
|
||||
|
||||
async deleteConversation(conversationId: string) {
|
||||
await this.execute<(typeof classroomio.agent.history)[':conversationId']['$delete']>({
|
||||
requestFn: () =>
|
||||
classroomio.agent.history[':conversationId'].$delete({
|
||||
param: { conversationId }
|
||||
}),
|
||||
logContext: 'deleting conversation',
|
||||
onSuccess: () => {
|
||||
this.conversations = this.conversations.filter((c) => c.id !== conversationId);
|
||||
|
||||
if (this.currentConversation?.id === conversationId) {
|
||||
this.currentConversation = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async uploadDocument(
|
||||
file: File,
|
||||
courseId: string,
|
||||
conversationId: string
|
||||
): Promise<{ documentId: string; fileName: string; wordCount: number; truncated: boolean } | null> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const url =
|
||||
`${getRequestBaseUrl()}/agent/upload` +
|
||||
`?courseId=${encodeURIComponent(courseId)}` +
|
||||
`&conversationId=${encodeURIComponent(conversationId)}`;
|
||||
|
||||
const response = await apiClient.request(url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include'
|
||||
});
|
||||
const result = (await response.json()) as {
|
||||
success: boolean;
|
||||
data?: { documentId: string; fileName: string; wordCount: number; truncated: boolean };
|
||||
};
|
||||
|
||||
if (result.success && result.data) {
|
||||
return result.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading document:', error);
|
||||
this.error = 'Failed to upload document';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async generateTitle(conversationId: string, firstMessageText: string): Promise<string | null> {
|
||||
let generatedTitle: string | null = null;
|
||||
|
||||
await this.execute<(typeof classroomio.agent.history)[':conversationId']['generate-title']['$post']>({
|
||||
requestFn: () =>
|
||||
classroomio.agent.history[':conversationId']['generate-title'].$post({
|
||||
param: { conversationId },
|
||||
json: { firstMessageText }
|
||||
}),
|
||||
logContext: 'generating title',
|
||||
onSuccess: (result) => {
|
||||
generatedTitle = (result.data as { title: string }).title;
|
||||
|
||||
// Update the title in the local conversations list
|
||||
this.conversations = this.conversations.map((c) =>
|
||||
c.id === conversationId ? { ...c, title: generatedTitle } : c
|
||||
);
|
||||
|
||||
if (this.currentConversation?.id === conversationId) {
|
||||
this.currentConversation = { ...this.currentConversation, title: generatedTitle };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return generatedTitle;
|
||||
}
|
||||
}
|
||||
|
||||
export const aiAssistantApi = new AiAssistantApi();
|
||||
@@ -0,0 +1,119 @@
|
||||
<script lang="ts">
|
||||
import * as Popover from '@cio/ui/base/popover';
|
||||
import SparklesIcon from '@lucide/svelte/icons/sparkles';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import HistoryIcon from '@lucide/svelte/icons/history';
|
||||
import ChatHistoryPopover from '$features/ai-assistant/chat-history-popover.svelte';
|
||||
import { closeAiAssistant } from '$features/ai-assistant/utils/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
|
||||
interface Conversation {
|
||||
id: string;
|
||||
title: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface TokenUsage {
|
||||
used: number;
|
||||
allowance: number;
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tokenUsage: TokenUsage | null;
|
||||
conversations: Conversation[];
|
||||
activeConversationId: string | null;
|
||||
conversationTitle: string | null;
|
||||
isNewChatDisabled: boolean;
|
||||
onNewChat: () => void;
|
||||
onLoadConversation: (id: string) => void;
|
||||
onDeleteConversation: (id: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
tokenUsage,
|
||||
conversations,
|
||||
activeConversationId,
|
||||
conversationTitle,
|
||||
isNewChatDisabled,
|
||||
onNewChat,
|
||||
onLoadConversation,
|
||||
onDeleteConversation
|
||||
}: Props = $props();
|
||||
|
||||
let historyPopoverOpen = $state(false);
|
||||
|
||||
function handleLoadConversation(id: string) {
|
||||
onLoadConversation(id);
|
||||
historyPopoverOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="border-b px-4 py-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<SparklesIcon size={18} class="ui:text-primary shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-sm font-semibold">{$t('course.navItems.nav_ai_assistant')}</h3>
|
||||
{#if conversationTitle && conversationTitle !== 'New conversation'}
|
||||
<p class="ui:text-muted-foreground truncate text-xs">{conversationTitle}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<IconButton
|
||||
onclick={onNewChat}
|
||||
disabled={isNewChatDisabled}
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
tooltip={$t('ai_assistant.new_chat')}
|
||||
>
|
||||
<PlusIcon size={16} />
|
||||
</IconButton>
|
||||
|
||||
<Popover.Root bind:open={historyPopoverOpen}>
|
||||
<Popover.Trigger>
|
||||
<IconButton variant="outline" size="icon-xs" tooltip={$t('ai_assistant.chat_history')}>
|
||||
<HistoryIcon size={16} />
|
||||
</IconButton>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="ui:p-0! w-72" align="end">
|
||||
<ChatHistoryPopover
|
||||
{conversations}
|
||||
{activeConversationId}
|
||||
onLoad={handleLoadConversation}
|
||||
onDelete={onDeleteConversation}
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
<IconButton onclick={closeAiAssistant} variant="outline" size="icon-xs">
|
||||
<XIcon size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
{#if tokenUsage && tokenUsage.allowance > 0}
|
||||
{@const usagePercent = Math.min(100, Math.round((tokenUsage.used / tokenUsage.allowance) * 100))}
|
||||
<div class="mt-2">
|
||||
<div class="ui:text-muted-foreground flex justify-between text-[10px]">
|
||||
<span
|
||||
>{tokenUsage.used.toLocaleString()} / {tokenUsage.allowance.toLocaleString()}
|
||||
{$t('ai_assistant.tokens_label')}</span
|
||||
>
|
||||
<span>{usagePercent}%</span>
|
||||
</div>
|
||||
<div class="ui:bg-muted mt-0.5 h-1 w-full rounded-full">
|
||||
<div
|
||||
class="h-1 rounded-full transition-all {usagePercent > 90
|
||||
? 'bg-red-500'
|
||||
: usagePercent > 70
|
||||
? 'bg-amber-500'
|
||||
: 'ui:bg-primary'}"
|
||||
style="width: {usagePercent}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import MessageSquareIcon from '@lucide/svelte/icons/message-square';
|
||||
import TrashIcon from '@lucide/svelte/icons/trash-2';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Conversation {
|
||||
id: string;
|
||||
title: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
conversations: Conversation[];
|
||||
activeConversationId: string | null;
|
||||
onLoad: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
let { conversations, activeConversationId, onLoad, onDelete }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="border-b px-3 py-2">
|
||||
<p class="text-xs font-medium">{$t('ai_assistant.chat_history')}</p>
|
||||
</div>
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
{#if conversations.length === 0}
|
||||
<p class="ui:text-muted-foreground px-3 py-4 text-center text-xs">{$t('ai_assistant.no_conversations')}</p>
|
||||
{:else}
|
||||
{#each conversations as conversation (conversation.id)}
|
||||
<div
|
||||
class="group flex items-center gap-2 border-b px-3 py-2 last:border-b-0 {conversation.id ===
|
||||
activeConversationId
|
||||
? 'ui:bg-muted'
|
||||
: 'hover:ui:bg-muted/50'} transition-colors"
|
||||
>
|
||||
<button onclick={() => onLoad(conversation.id)} class="flex min-w-0 flex-1 items-center gap-2 text-left">
|
||||
<MessageSquareIcon size={14} class="ui:text-muted-foreground flex-shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-xs font-medium">{conversation.title || $t('ai_assistant.untitled')}</p>
|
||||
<p class="ui:text-muted-foreground text-[10px]">
|
||||
{new Date(conversation.updatedAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => onDelete(conversation.id)}
|
||||
class="ui:text-muted-foreground flex-shrink-0 rounded p-1 opacity-0 transition-all group-hover:opacity-100 hover:text-red-500"
|
||||
>
|
||||
<TrashIcon size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,190 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { ChatTextarea, type MentionItem } from '@cio/ui/custom/chat-textarea';
|
||||
import { LessonIcon, ExerciseIcon } from '@cio/ui/custom/moving-icons';
|
||||
import PaperclipIcon from '@lucide/svelte/icons/paperclip';
|
||||
import SquareIcon from '@lucide/svelte/icons/square';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import FileTextIcon from '@lucide/svelte/icons/file-text';
|
||||
import LoaderIcon from '@lucide/svelte/icons/loader';
|
||||
import TableOfContentsIcon from '@lucide/svelte/icons/table-of-contents';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
import { openUpgradeModal } from '$lib/utils/functions/org';
|
||||
import ModelPicker from '$features/ai-assistant/model-picker.svelte';
|
||||
import type { AgentModelId } from '@cio/utils/agent-models';
|
||||
|
||||
interface UploadedDocument {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
inputValue: string;
|
||||
isStreaming: boolean;
|
||||
isExhausted: boolean;
|
||||
isUploading: boolean;
|
||||
error: Error | null | undefined;
|
||||
mentionItems: MentionItem[];
|
||||
uploadedDocument: UploadedDocument | null;
|
||||
selectedModel: AgentModelId;
|
||||
onSend: () => void;
|
||||
onStop: () => void;
|
||||
onFileSelect: (file: File) => void;
|
||||
onRemoveDocument: () => void;
|
||||
onSelectModel: (id: AgentModelId) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
inputValue = $bindable(),
|
||||
isStreaming,
|
||||
isExhausted,
|
||||
isUploading,
|
||||
error,
|
||||
mentionItems,
|
||||
uploadedDocument,
|
||||
selectedModel,
|
||||
onSend,
|
||||
onStop,
|
||||
onFileSelect,
|
||||
onRemoveDocument,
|
||||
onSelectModel
|
||||
}: Props = $props();
|
||||
|
||||
let fileInputEl: HTMLInputElement | undefined = $state();
|
||||
let chatTextareaRef: HTMLTextAreaElement | null = $state(null);
|
||||
let wasBusy = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const isBusy = isStreaming || isUploading;
|
||||
|
||||
if (isBusy) {
|
||||
wasBusy = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!wasBusy || isExhausted || !chatTextareaRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
wasBusy = false;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
chatTextareaRef?.focus();
|
||||
});
|
||||
});
|
||||
|
||||
function handlePaperclipClick() {
|
||||
if ($isFreePlan) {
|
||||
openUpgradeModal();
|
||||
return;
|
||||
}
|
||||
fileInputEl?.click();
|
||||
}
|
||||
|
||||
function handleFileChange(e: Event) {
|
||||
const target = e.currentTarget as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
if (file) {
|
||||
onFileSelect(file);
|
||||
target.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeLabel(item: MentionItem) {
|
||||
if (item.type === 'EXERCISE') return t.get('ai_assistant.mention_exercise');
|
||||
if (item.type === 'SECTION') return t.get('ai_assistant.mention_section');
|
||||
|
||||
return t.get('ai_assistant.mention_lesson');
|
||||
}
|
||||
</script>
|
||||
|
||||
<input
|
||||
bind:this={fileInputEl}
|
||||
type="file"
|
||||
accept=".pdf,.docx,.pptx,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
class="hidden"
|
||||
onchange={handleFileChange}
|
||||
/>
|
||||
|
||||
<div class="border-t px-3 pt-3 pb-1.5">
|
||||
{#if isExhausted}
|
||||
<div
|
||||
class="rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300"
|
||||
>
|
||||
{$t('ai_assistant.tokens_exhausted')}
|
||||
</div>
|
||||
{:else}
|
||||
{#if error}
|
||||
<div
|
||||
class="mb-2 rounded border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300"
|
||||
>
|
||||
{error.message}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isUploading}
|
||||
<div class="mb-2 flex items-center gap-2 rounded border px-3 py-2 text-xs">
|
||||
<LoaderIcon size={12} class="animate-spin" />
|
||||
<span class="ui:text-muted-foreground">{$t('ai_assistant.uploading_document')}</span>
|
||||
</div>
|
||||
{:else if uploadedDocument}
|
||||
<div class="mb-2 flex items-center gap-2 rounded border px-3 py-2 text-xs">
|
||||
<FileTextIcon size={12} class="ui:text-primary shrink-0" />
|
||||
<span class="min-w-0 flex-1 truncate">{uploadedDocument.name}</span>
|
||||
<button
|
||||
onclick={onRemoveDocument}
|
||||
class="ui:text-muted-foreground hover:ui:text-foreground shrink-0 rounded p-0.5 transition-colors"
|
||||
>
|
||||
<XIcon size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-end gap-2">
|
||||
<ChatTextarea
|
||||
bind:ref={chatTextareaRef}
|
||||
bind:value={inputValue}
|
||||
{mentionItems}
|
||||
onSubmit={onSend}
|
||||
placeholder={$t('ai_assistant.input_placeholder')}
|
||||
disabled={isStreaming || isUploading}
|
||||
typeLabel={getTypeLabel}
|
||||
emptyMessage={t.get('ai_assistant.mention_no_results')}
|
||||
rows={2}
|
||||
class="flex-1"
|
||||
>
|
||||
{#snippet icon({ item })}
|
||||
{#if item.type === 'EXERCISE'}
|
||||
<ExerciseIcon size={14} />
|
||||
{:else if item.type === 'SECTION'}
|
||||
<TableOfContentsIcon size={14} />
|
||||
{:else}
|
||||
<LessonIcon size={14} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet actions()}
|
||||
<button
|
||||
onclick={handlePaperclipClick}
|
||||
disabled={isUploading}
|
||||
class="ui:text-muted-foreground hover:ui:bg-muted shrink-0 rounded-md p-1.5 transition-colors disabled:pointer-events-none disabled:opacity-40"
|
||||
title={$isFreePlan ? $t('ai_assistant.upgrade_to_upload') : $t('ai_assistant.attach_document')}
|
||||
>
|
||||
<PaperclipIcon size={16} />
|
||||
</button>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
{#if isStreaming}
|
||||
<Button size="icon" variant="outline" onclick={onStop} class="size-7 shrink-0">
|
||||
<SquareIcon size={12} />
|
||||
</Button>
|
||||
{:else}
|
||||
<ModelPicker value={selectedModel} disabled={isStreaming} onChange={onSelectModel} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ChatTextarea>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,143 @@
|
||||
<script lang="ts">
|
||||
import SparklesIcon from '@lucide/svelte/icons/sparkles';
|
||||
import LoaderIcon from '@lucide/svelte/icons/loader';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import MessageBubble from '$features/ai-assistant/message-bubble.svelte';
|
||||
import ProgressCard from '$features/ai-assistant/progress-card.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { ProgressStep } from '$features/ai-assistant/utils/tool-labels';
|
||||
import type { AiAssistantMessage } from '$features/ai-assistant/utils/types';
|
||||
|
||||
interface PlanExecutionState {
|
||||
title: string;
|
||||
steps: ProgressStep[];
|
||||
currentAction?: string;
|
||||
isStopped: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
messages: AiAssistantMessage[];
|
||||
status: string;
|
||||
isStreaming: boolean;
|
||||
courseId: string;
|
||||
planExecutionState: PlanExecutionState | null;
|
||||
quickActions: string[];
|
||||
onQuickAction: (action: string) => void;
|
||||
onImplementPlan: (editedPlan: unknown) => void;
|
||||
onAskPlanChanges: () => void;
|
||||
onStop: () => void;
|
||||
onResume: () => void;
|
||||
onMentionClick: (route: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
messages,
|
||||
status,
|
||||
isStreaming,
|
||||
courseId,
|
||||
planExecutionState,
|
||||
quickActions,
|
||||
onQuickAction,
|
||||
onImplementPlan,
|
||||
onAskPlanChanges,
|
||||
onStop,
|
||||
onResume,
|
||||
onMentionClick
|
||||
}: Props = $props();
|
||||
|
||||
let messagesContainer: HTMLDivElement | undefined = $state();
|
||||
let lastMessageCount = $state(0);
|
||||
let lastStepsCount = $state(0);
|
||||
let lastCurrentAction = $state('');
|
||||
|
||||
const isEmpty = $derived(messages.length === 0);
|
||||
|
||||
function scrollToBottom() {
|
||||
requestAnimationFrame(() => {
|
||||
messagesContainer?.scrollTo({ top: messagesContainer.scrollHeight, behavior: 'smooth' });
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!messagesContainer || messages.length === 0) return;
|
||||
|
||||
if (messages.length === lastMessageCount) return;
|
||||
|
||||
lastMessageCount = messages.length;
|
||||
scrollToBottom();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!messagesContainer || !planExecutionState) return;
|
||||
|
||||
const stepsCount = planExecutionState.steps.length;
|
||||
if (stepsCount !== lastStepsCount) {
|
||||
lastStepsCount = stepsCount;
|
||||
scrollToBottom();
|
||||
return;
|
||||
}
|
||||
|
||||
const action = planExecutionState.currentAction ?? '';
|
||||
if (action !== lastCurrentAction) {
|
||||
lastCurrentAction = action;
|
||||
scrollToBottom();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-4" bind:this={messagesContainer}>
|
||||
{#if isEmpty}
|
||||
<!-- Empty state with quick actions -->
|
||||
<div class="flex h-full flex-col items-center justify-center gap-4">
|
||||
<div class="flex flex-col items-center gap-2 text-center">
|
||||
<SparklesIcon size={32} class="ui:text-muted-foreground" />
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
{$t('ai_assistant.empty_state')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-center gap-2">
|
||||
{#each quickActions as action (action)}
|
||||
<button
|
||||
onclick={() => onQuickAction(action)}
|
||||
class="ui:text-muted-foreground hover:ui:bg-muted cursor-pointer rounded-full border px-3 py-1.5 text-xs transition-colors"
|
||||
>
|
||||
{action}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Message list -->
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each messages as message (message.id)}
|
||||
<MessageBubble {message} {courseId} {onImplementPlan} {onAskPlanChanges} {onMentionClick} />
|
||||
{/each}
|
||||
|
||||
{#if status === 'submitted' && isStreaming}
|
||||
<div class="flex items-start">
|
||||
<div class="ui:bg-muted rounded-lg px-3 py-2">
|
||||
<LoaderIcon size={16} class="ui:text-muted-foreground animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if planExecutionState}
|
||||
<div class="mt-2">
|
||||
<ProgressCard
|
||||
title={planExecutionState.title}
|
||||
steps={planExecutionState.steps}
|
||||
currentAction={planExecutionState.currentAction}
|
||||
onStop={isStreaming ? onStop : undefined}
|
||||
isStopped={planExecutionState.isStopped}
|
||||
/>
|
||||
{#if planExecutionState.isStopped}
|
||||
<Button size="sm" variant="outline" onclick={onResume} class="mt-2 w-full"
|
||||
>{$t('ai_assistant.resume')}</Button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as AiCourseChat } from './ai-course-chat.svelte';
|
||||
export { default as PlanView } from './plan-view.svelte';
|
||||
export { default as ProgressCard } from './progress-card.svelte';
|
||||
export { isAiAssistantOpen, openAiAssistant, closeAiAssistant, toggleAiAssistant } from './utils/store';
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { MentionPopover, type MentionItem } from '@cio/ui/custom/mention-popover';
|
||||
import { LessonIcon, ExerciseIcon } from '@cio/ui/custom/moving-icons';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface ContentMentionItem {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: ContentMentionItem[];
|
||||
query: string;
|
||||
selectedIndex: number;
|
||||
onSelect: (item: ContentMentionItem) => void;
|
||||
}
|
||||
|
||||
let { items, query, selectedIndex, onSelect }: Props = $props();
|
||||
|
||||
const mentionItems: MentionItem[] = $derived(
|
||||
items.map((item) => ({ id: item.id, label: item.title, type: item.type }))
|
||||
);
|
||||
|
||||
function handleSelect(mentionItem: MentionItem) {
|
||||
const original = items.find((item) => item.id === mentionItem.id);
|
||||
|
||||
if (original) {
|
||||
onSelect(original);
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeLabel(item: MentionItem) {
|
||||
return item.type === 'EXERCISE' ? t.get('ai_assistant.mention_exercise') : t.get('ai_assistant.mention_lesson');
|
||||
}
|
||||
</script>
|
||||
|
||||
<MentionPopover
|
||||
items={mentionItems}
|
||||
{query}
|
||||
{selectedIndex}
|
||||
onSelect={handleSelect}
|
||||
typeLabel={getTypeLabel}
|
||||
emptyMessage={t.get('ai_assistant.mention_no_results')}
|
||||
>
|
||||
{#snippet icon({ item })}
|
||||
{#if item.type === 'EXERCISE'}
|
||||
<ExerciseIcon size={14} />
|
||||
{:else}
|
||||
<LessonIcon size={14} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</MentionPopover>
|
||||
@@ -0,0 +1,205 @@
|
||||
<script lang="ts">
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import AlertCircleIcon from '@lucide/svelte/icons/alert-circle';
|
||||
import CircleIcon from '@lucide/svelte/icons/circle';
|
||||
import LoaderIcon from '@lucide/svelte/icons/loader';
|
||||
import CopyIcon from '@lucide/svelte/icons/copy';
|
||||
import FileTextIcon from '@lucide/svelte/icons/file-text';
|
||||
import { renderMarkdown } from '$features/ai-assistant/utils/markdown';
|
||||
import { renderMentions } from '$features/ai-assistant/utils/mentions';
|
||||
import PlanView from '$features/ai-assistant/plan-view.svelte';
|
||||
import { getToolLabel, getToolResultLabel } from '$features/ai-assistant/utils/tool-labels';
|
||||
import {
|
||||
getAgentToolName,
|
||||
getAgentToolResult,
|
||||
getAgentToolStatus,
|
||||
isAgentToolPart
|
||||
} from '$features/ai-assistant/utils/tool-parts';
|
||||
import type { AiAssistantMessage, AiAssistantMessageMetadata } from '$features/ai-assistant/utils/types';
|
||||
|
||||
interface Props {
|
||||
message: AiAssistantMessage;
|
||||
courseId: string;
|
||||
onImplementPlan: (editedPlan: unknown) => void;
|
||||
onAskPlanChanges: () => void;
|
||||
onMentionClick: (route: string) => void;
|
||||
}
|
||||
|
||||
let { message, courseId, onImplementPlan, onAskPlanChanges, onMentionClick }: Props = $props();
|
||||
|
||||
function handleBubbleClick(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
const mentionLink = target.closest('[data-mention-route]') as HTMLElement | null;
|
||||
|
||||
if (mentionLink) {
|
||||
event.preventDefault();
|
||||
const route = mentionLink.dataset.mentionRoute;
|
||||
|
||||
if (route) {
|
||||
onMentionClick(route);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const messageAttachment = $derived((message.metadata as AiAssistantMessageMetadata | undefined)?.attachment);
|
||||
|
||||
function isDeferredPlanPart(part: Record<string, unknown>) {
|
||||
if (!isAgentToolPart(part)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return getAgentToolName(part) === 'generate_course_plan';
|
||||
}
|
||||
|
||||
function truncateErrorText(errorText: string): string {
|
||||
// Show just the summary before the raw Value JSON dump
|
||||
const valueIndex = errorText.indexOf(' Value: ');
|
||||
if (valueIndex !== -1) {
|
||||
return errorText.slice(0, valueIndex);
|
||||
}
|
||||
|
||||
return errorText.length > 120 ? errorText.slice(0, 120) + '…' : errorText;
|
||||
}
|
||||
|
||||
function copyToClipboard(text: string) {
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
function getPartErrorText(part: unknown): string | undefined {
|
||||
return (part as { errorText?: string }).errorText;
|
||||
}
|
||||
|
||||
const inlineParts = $derived(message.parts.filter((part) => !isDeferredPlanPart(part as Record<string, unknown>)));
|
||||
const deferredPlanParts = $derived(
|
||||
message.parts.filter((part) => isDeferredPlanPart(part as Record<string, unknown>))
|
||||
);
|
||||
</script>
|
||||
|
||||
<div data-role={message.role} class="flex flex-col gap-1 {message.role === 'user' ? 'items-end' : 'items-start'}">
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="max-w-[90%] rounded-lg px-3 py-2 text-sm {message.role === 'user'
|
||||
? 'ui:bg-primary ui:text-primary-foreground'
|
||||
: 'ui:bg-muted'}"
|
||||
onclick={handleBubbleClick}
|
||||
>
|
||||
{#if messageAttachment}
|
||||
<div
|
||||
class="mb-2 flex items-center gap-2 rounded-md px-2 py-1 text-xs {message.role === 'user'
|
||||
? 'bg-white/15 text-white/90'
|
||||
: 'ui:bg-background/70 ui:text-muted-foreground'}"
|
||||
>
|
||||
<FileTextIcon size={12} class="shrink-0" />
|
||||
<span class="min-w-0 truncate">{messageAttachment.name}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each inlineParts as part, partIndex (partIndex)}
|
||||
{#if part.type === 'text'}
|
||||
<div
|
||||
class="prose prose-sm dark:prose-invert max-w-none {message.role === 'user' && 'ui:text-primary-foreground!'}"
|
||||
>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html renderMentions(renderMarkdown(part.text as string), courseId)}
|
||||
</div>
|
||||
{:else if isAgentToolPart(part)}
|
||||
{@const toolName = getAgentToolName(part)}
|
||||
{@const toolResult = getAgentToolResult(part)}
|
||||
{@const toolStatus = getAgentToolStatus(part)}
|
||||
{@const errorText = getPartErrorText(part)}
|
||||
{#if toolName === 'generate_course_plan' && toolStatus === 'completed'}
|
||||
<PlanView plan={toolResult} onImplement={onImplementPlan} onAskChanges={onAskPlanChanges} />
|
||||
{:else}
|
||||
<div class="ui:bg-background/70 flex items-center gap-2 rounded-md px-2 py-1.5 text-xs">
|
||||
{#if toolStatus === 'completed' && toolName}
|
||||
<CheckIcon size={12} class="ui:text-primary shrink-0" />
|
||||
<span>{getToolResultLabel(toolName, toolResult)}</span>
|
||||
{:else if toolStatus === 'failed' && toolName}
|
||||
<AlertCircleIcon size={12} class="shrink-0 text-red-500" />
|
||||
<span class="min-w-0 flex-1 truncate text-red-600 dark:text-red-400">
|
||||
{errorText ? truncateErrorText(errorText) : `${getToolLabel(toolName)} failed`}
|
||||
</span>
|
||||
{#if errorText}
|
||||
<button
|
||||
type="button"
|
||||
class="ui:text-muted-foreground ml-1 shrink-0 cursor-pointer hover:text-red-500"
|
||||
title="Copy full error"
|
||||
onclick={() => copyToClipboard(errorText)}
|
||||
>
|
||||
<CopyIcon size={11} />
|
||||
</button>
|
||||
{/if}
|
||||
{:else if toolStatus === 'in_progress' && toolName}
|
||||
<LoaderIcon size={12} class="ui:text-primary shrink-0 animate-spin" />
|
||||
<span>{getToolLabel(toolName)}</span>
|
||||
{:else}
|
||||
<CircleIcon size={12} class="ui:text-muted-foreground shrink-0" />
|
||||
<span class="ui:text-muted-foreground">{toolName ? getToolLabel(toolName) : 'Working'}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#each deferredPlanParts as part, partIndex (partIndex)}
|
||||
{@const toolName = getAgentToolName(part)}
|
||||
{@const toolResult = getAgentToolResult(part)}
|
||||
{@const toolStatus = getAgentToolStatus(part)}
|
||||
{@const errorText = getPartErrorText(part)}
|
||||
{#if toolName === 'generate_course_plan' && toolStatus === 'completed'}
|
||||
<div class="mt-3">
|
||||
<PlanView plan={toolResult} onImplement={onImplementPlan} onAskChanges={onAskPlanChanges} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="ui:bg-background/70 mt-3 flex items-center gap-2 rounded-md px-2 py-1.5 text-xs">
|
||||
{#if toolStatus === 'completed' && toolName}
|
||||
<CheckIcon size={12} class="ui:text-primary shrink-0" />
|
||||
<span>{getToolResultLabel(toolName, toolResult)}</span>
|
||||
{:else if toolStatus === 'failed' && toolName}
|
||||
<AlertCircleIcon size={12} class="shrink-0 text-red-500" />
|
||||
<span class="min-w-0 flex-1 truncate text-red-600 dark:text-red-400">
|
||||
{errorText ? truncateErrorText(errorText) : `${getToolLabel(toolName)} failed`}
|
||||
</span>
|
||||
{#if errorText}
|
||||
<button
|
||||
type="button"
|
||||
class="ui:text-muted-foreground ml-1 shrink-0 cursor-pointer hover:text-red-500"
|
||||
title="Copy full error"
|
||||
onclick={() => copyToClipboard(errorText)}
|
||||
>
|
||||
<CopyIcon size={11} />
|
||||
</button>
|
||||
{/if}
|
||||
{:else if toolStatus === 'in_progress' && toolName}
|
||||
<LoaderIcon size={12} class="ui:text-primary shrink-0 animate-spin" />
|
||||
<span>{getToolLabel(toolName)}</span>
|
||||
{:else}
|
||||
<CircleIcon size={12} class="ui:text-muted-foreground shrink-0" />
|
||||
<span class="ui:text-muted-foreground">{toolName ? getToolLabel(toolName) : 'Working'}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.mention-link) {
|
||||
display: inline;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
text-underline-offset: 2px;
|
||||
border-radius: 0.125rem;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
:global([data-role='user'] a) {
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
:global(.mention-link:hover) {
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import * as Popover from '@cio/ui/base/popover';
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import { AGENT_MODELS, UI_PICKER_MODEL_IDS, type AgentModelId } from '@cio/utils/agent-models';
|
||||
|
||||
interface Props {
|
||||
value: AgentModelId;
|
||||
disabled?: boolean;
|
||||
onChange: (id: AgentModelId) => void;
|
||||
}
|
||||
|
||||
let { value, disabled = false, onChange }: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
|
||||
function handleSelect(id: AgentModelId) {
|
||||
onChange(id);
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger
|
||||
{disabled}
|
||||
class="ui:text-muted-foreground hover:ui:bg-muted ui:hover:text-foreground flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors disabled:pointer-events-none disabled:opacity-40"
|
||||
>
|
||||
<span class="truncate">{AGENT_MODELS[value].label}</span>
|
||||
<ChevronDownIcon size={12} />
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content class="ui:p-0! ui:z-[110] w-48" align="end" side="top">
|
||||
<div class="py-1">
|
||||
{#each UI_PICKER_MODEL_IDS as id (id)}
|
||||
{@const isSelected = id === value}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleSelect(id)}
|
||||
class="hover:ui:bg-muted flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-xs transition-colors"
|
||||
>
|
||||
<span class="min-w-0 truncate">{AGENT_MODELS[id].label}</span>
|
||||
{#if isSelected}
|
||||
<CheckIcon size={14} class="ui:text-primary shrink-0" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
@@ -0,0 +1,263 @@
|
||||
<script lang="ts">
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
import BookOpenIcon from '@lucide/svelte/icons/book-open';
|
||||
import FileQuestionIcon from '@lucide/svelte/icons/file-question';
|
||||
import PencilIcon from '@lucide/svelte/icons/pencil';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
/** Token cost estimation heuristics (tokens per item) */
|
||||
const TOKEN_COST_ESTIMATES = {
|
||||
SECTION_CREATE: 500,
|
||||
LESSON_CREATE_AND_CONTENT: 3000,
|
||||
EXERCISE_CREATE: 1500
|
||||
} as const;
|
||||
|
||||
interface PlanItem {
|
||||
type: 'lesson' | 'exercise';
|
||||
title: string;
|
||||
description: string;
|
||||
order: number;
|
||||
hasExercise: boolean;
|
||||
}
|
||||
|
||||
interface PlanSection {
|
||||
title: string;
|
||||
order: number;
|
||||
items: PlanItem[];
|
||||
}
|
||||
|
||||
interface CoursePlan {
|
||||
title: string;
|
||||
sections: PlanSection[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
plan: CoursePlan;
|
||||
onImplement: (editedPlan: CoursePlan) => void;
|
||||
onAskChanges: () => void;
|
||||
remainingTokens?: number;
|
||||
}
|
||||
|
||||
let { plan, onImplement, onAskChanges, remainingTokens }: Props = $props();
|
||||
|
||||
// CoursePlan is plain JSON; structuredClone fails when the AI SDK part carries
|
||||
// non-cloneable internals, so JSON round-trip is both safer and sufficient.
|
||||
function clonePlan(input: CoursePlan): CoursePlan {
|
||||
return JSON.parse(JSON.stringify(input)) as CoursePlan;
|
||||
}
|
||||
|
||||
// Local editable copy of the plan
|
||||
let editablePlan: CoursePlan = $state(clonePlan(plan));
|
||||
|
||||
// Reset editable plan when the prop changes (e.g., agent revises the plan)
|
||||
$effect(() => {
|
||||
editablePlan = clonePlan(plan);
|
||||
});
|
||||
|
||||
let expandedSections = new SvelteSet<number>();
|
||||
|
||||
$effect(() => {
|
||||
expandedSections.clear();
|
||||
|
||||
for (let i = 0; i < editablePlan.sections.length; i++) {
|
||||
expandedSections.add(i);
|
||||
}
|
||||
});
|
||||
|
||||
// Track which field is being edited: "section-0-title", "lesson-1-2-title", "lesson-1-2-desc"
|
||||
let editingField: string | null = $state(null);
|
||||
|
||||
function toggleSection(index: number) {
|
||||
if (expandedSections.has(index)) {
|
||||
expandedSections.delete(index);
|
||||
} else {
|
||||
expandedSections.add(index);
|
||||
}
|
||||
}
|
||||
|
||||
function startEditing(fieldKey: string) {
|
||||
editingField = fieldKey;
|
||||
}
|
||||
|
||||
function stopEditing() {
|
||||
editingField = null;
|
||||
}
|
||||
|
||||
function handleEditKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
stopEditing();
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
stopEditing();
|
||||
}
|
||||
}
|
||||
|
||||
const totalLessons = $derived(
|
||||
editablePlan.sections.reduce((sum, s) => sum + s.items.filter((i) => i.type === 'lesson').length, 0)
|
||||
);
|
||||
const totalExercises = $derived(
|
||||
editablePlan.sections.reduce(
|
||||
(sum, s) => sum + s.items.filter((i) => i.type === 'exercise' || i.hasExercise).length,
|
||||
0
|
||||
)
|
||||
);
|
||||
|
||||
const estimatedTokens = $derived(
|
||||
editablePlan.sections.length * TOKEN_COST_ESTIMATES.SECTION_CREATE +
|
||||
totalLessons * TOKEN_COST_ESTIMATES.LESSON_CREATE_AND_CONTENT +
|
||||
totalExercises * TOKEN_COST_ESTIMATES.EXERCISE_CREATE
|
||||
);
|
||||
|
||||
const costPercentage = $derived(remainingTokens ? Math.round((estimatedTokens / remainingTokens) * 100) : null);
|
||||
const isHighCost = $derived(costPercentage !== null && costPercentage > 50);
|
||||
</script>
|
||||
|
||||
<div class="ui:bg-background rounded-lg border">
|
||||
<!-- Plan header -->
|
||||
<div class="border-b px-3 py-2">
|
||||
{#if editingField === 'plan-title'}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editablePlan.title}
|
||||
onblur={stopEditing}
|
||||
onkeydown={handleEditKeydown}
|
||||
class="ui:bg-muted/50 focus:ui:ring-primary w-full rounded border px-1 py-0.5 text-sm font-semibold focus:ring-1 focus:outline-none"
|
||||
autofocus
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => startEditing('plan-title')}
|
||||
class="group hover:ui:text-primary flex items-center gap-1 text-left text-sm font-semibold"
|
||||
>
|
||||
{editablePlan.title}
|
||||
<PencilIcon size={10} class="opacity-0 group-hover:opacity-50" />
|
||||
</button>
|
||||
{/if}
|
||||
<p class="ui:text-muted-foreground text-xs">
|
||||
{editablePlan.sections.length} sections, {totalLessons} lessons{totalExercises > 0
|
||||
? `, ${totalExercises} exercises`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Sections tree -->
|
||||
<div class="max-h-[300px] overflow-y-auto p-2">
|
||||
{#each editablePlan.sections as section, sectionIndex (sectionIndex)}
|
||||
<div class="mb-1">
|
||||
<div class="flex w-full items-center gap-1.5 rounded px-2 py-1.5 text-xs font-medium">
|
||||
<button onclick={() => toggleSection(sectionIndex)} class="flex-shrink-0">
|
||||
{#if expandedSections.has(sectionIndex)}
|
||||
<ChevronDownIcon size={12} />
|
||||
{:else}
|
||||
<ChevronRightIcon size={12} />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if editingField === `section-${sectionIndex}-title`}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editablePlan.sections[sectionIndex].title}
|
||||
onblur={stopEditing}
|
||||
onkeydown={handleEditKeydown}
|
||||
class="ui:bg-muted/50 focus:ui:ring-primary flex-1 rounded border px-1 py-0.5 text-xs focus:ring-1 focus:outline-none"
|
||||
autofocus
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => startEditing(`section-${sectionIndex}-title`)}
|
||||
class="group hover:ui:text-primary flex items-center gap-1 text-left"
|
||||
>
|
||||
Section {section.order}: {section.title}
|
||||
<PencilIcon size={9} class="opacity-0 group-hover:opacity-50" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if expandedSections.has(sectionIndex)}
|
||||
<div class="ml-5 space-y-1">
|
||||
{#each section.items as item, itemIndex (item.order)}
|
||||
<div class="flex items-start gap-1.5 rounded px-2 py-1 text-xs">
|
||||
{#if item.type === 'exercise'}
|
||||
<FileQuestionIcon size={11} class="ui:text-primary mt-0.5 flex-shrink-0" />
|
||||
{:else}
|
||||
<BookOpenIcon size={11} class="ui:text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
{#if editingField === `item-${sectionIndex}-${itemIndex}-title`}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editablePlan.sections[sectionIndex].items[itemIndex].title}
|
||||
onblur={stopEditing}
|
||||
onkeydown={handleEditKeydown}
|
||||
class="ui:bg-muted/50 focus:ui:ring-primary w-full rounded border px-1 py-0.5 text-xs font-medium focus:ring-1 focus:outline-none"
|
||||
autofocus
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => startEditing(`item-${sectionIndex}-${itemIndex}-title`)}
|
||||
class="group hover:ui:text-primary flex items-center gap-1 text-left font-medium"
|
||||
>
|
||||
{#if item.type === 'exercise'}
|
||||
<span class="ui:text-primary">{item.title}</span>
|
||||
{:else}
|
||||
{item.title}
|
||||
{/if}
|
||||
<PencilIcon size={9} class="opacity-0 group-hover:opacity-50" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if editingField === `item-${sectionIndex}-${itemIndex}-desc`}
|
||||
<textarea
|
||||
bind:value={editablePlan.sections[sectionIndex].items[itemIndex].description}
|
||||
onblur={stopEditing}
|
||||
onkeydown={handleEditKeydown}
|
||||
rows={2}
|
||||
class="ui:bg-muted/50 ui:text-muted-foreground focus:ui:ring-primary mt-0.5 w-full rounded border px-1 py-0.5 text-xs focus:ring-1 focus:outline-none"
|
||||
autofocus
|
||||
></textarea>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => startEditing(`item-${sectionIndex}-${itemIndex}-desc`)}
|
||||
class="group ui:text-muted-foreground hover:ui:text-foreground mt-0.5 flex items-start gap-1 text-left"
|
||||
>
|
||||
<span class="line-clamp-2">{item.description}</span>
|
||||
<PencilIcon size={9} class="mt-0.5 flex-shrink-0 opacity-0 group-hover:opacity-50" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if item.type === 'lesson' && item.hasExercise}
|
||||
<div class="ui:text-muted-foreground mt-0.5 flex items-center gap-1">
|
||||
<FileQuestionIcon size={10} />
|
||||
<span>Exercise included</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Cost estimate + actions -->
|
||||
<div class="space-y-2 border-t px-3 py-2">
|
||||
{#if estimatedTokens > 0}
|
||||
<p class="text-xs {isHighCost ? 'text-amber-600 dark:text-amber-400' : 'ui:text-muted-foreground'}">
|
||||
Estimated cost: ~{estimatedTokens.toLocaleString()} tokens
|
||||
{#if costPercentage !== null}
|
||||
({costPercentage}% of remaining)
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button size="sm" onclick={() => onImplement(editablePlan)} class="flex-1">Implement Plan</Button>
|
||||
<Button size="sm" variant="outline" onclick={onAskChanges} class="flex-1">Ask for Changes</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import LoaderIcon from '@lucide/svelte/icons/loader';
|
||||
import CircleIcon from '@lucide/svelte/icons/circle';
|
||||
import SquareIcon from '@lucide/svelte/icons/square';
|
||||
import AlertCircleIcon from '@lucide/svelte/icons/alert-circle';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface ProgressStep {
|
||||
label: string;
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
indent?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
steps: ProgressStep[];
|
||||
currentAction?: string;
|
||||
onStop?: () => void;
|
||||
isStopped?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
let { title, steps, currentAction, onStop, isStopped = false, error }: Props = $props();
|
||||
|
||||
const completedCount = $derived(steps.filter((s) => s.status === 'completed').length);
|
||||
const totalCount = $derived(steps.length);
|
||||
</script>
|
||||
|
||||
<div class="ui:bg-background rounded-lg border">
|
||||
<div class="border-b px-3 py-2">
|
||||
<h4 class="text-sm font-semibold">{title}</h4>
|
||||
<p class="ui:text-muted-foreground text-xs">
|
||||
{completedCount} / {totalCount} steps completed
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="max-h-75 space-y-0.5 overflow-y-auto p-2">
|
||||
{#each steps as step, i (`${i}-${step.label}`)}
|
||||
<div class="flex items-center gap-2 rounded px-2 py-1 text-xs {step.indent ? 'ml-4' : ''}">
|
||||
{#if step.status === 'completed'}
|
||||
<CheckIcon size={12} class="ui:text-primary shrink-0" />
|
||||
{:else if step.status === 'in_progress'}
|
||||
<LoaderIcon size={12} class="ui:text-primary shrink-0 animate-spin" />
|
||||
{:else if step.status === 'failed'}
|
||||
<AlertCircleIcon size={12} class="shrink-0 text-red-500" />
|
||||
{:else}
|
||||
<CircleIcon size={12} class="ui:text-muted-foreground shrink-0" />
|
||||
{/if}
|
||||
<span class={step.status === 'completed' ? '' : step.status === 'pending' ? 'ui:text-muted-foreground' : ''}>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="space-y-1 border-t px-3 py-2">
|
||||
{#if error}
|
||||
<p class="text-xs text-red-600 dark:text-red-400">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if currentAction && !isStopped}
|
||||
<p class="ui:text-muted-foreground text-xs">{currentAction}</p>
|
||||
{/if}
|
||||
|
||||
{#if isStopped}
|
||||
<p class="ui:text-muted-foreground text-xs">{$t('ai_assistant.stopped_content_kept')}</p>
|
||||
{/if}
|
||||
|
||||
{#if onStop && !isStopped && completedCount < totalCount}
|
||||
<Button size="sm" variant="outline" onclick={onStop} class="w-full">
|
||||
<SquareIcon size={12} class="mr-1" />
|
||||
Stop
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import LoaderIcon from '@lucide/svelte/icons/loader';
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import { getToolLabel, getToolResultLabel } from '$features/ai-assistant/utils/tool-labels';
|
||||
|
||||
interface Props {
|
||||
toolName: string;
|
||||
state: string;
|
||||
result?: unknown;
|
||||
}
|
||||
|
||||
let { toolName, state, result }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="ui:bg-background/50 flex items-center gap-2 rounded border px-2 py-1 text-xs">
|
||||
{#if state === 'call' || state === 'partial-call'}
|
||||
<LoaderIcon size={12} class="animate-spin" />
|
||||
<span class="ui:text-muted-foreground">
|
||||
{getToolLabel(toolName)}...
|
||||
</span>
|
||||
{:else if state === 'result'}
|
||||
<CheckIcon size={12} class="ui:text-primary" />
|
||||
<span>
|
||||
{getToolResultLabel(toolName, result)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
export const AI_COURSE_CHAT_DEFAULT_WIDTH = 360;
|
||||
export const AI_COURSE_CHAT_MIN_WIDTH = 280;
|
||||
export const AI_COURSE_CHAT_MAX_WIDTH = 520;
|
||||
export const AI_COURSE_CHAT_STORAGE_KEY = 'classroomio-ai-course-chat-width';
|
||||
export const AI_CHAT_MODEL_STORAGE_KEY = 'classroomio-ai-chat-model';
|
||||
@@ -0,0 +1,17 @@
|
||||
import { marked } from 'marked';
|
||||
|
||||
// Configure marked for safe, minimal output
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true
|
||||
});
|
||||
|
||||
/**
|
||||
* Convert markdown text to sanitized HTML for rendering in the chat.
|
||||
* Agent responses come as markdown — this makes them render properly.
|
||||
*/
|
||||
export function renderMarkdown(text: string): string {
|
||||
if (!text) return '';
|
||||
|
||||
return marked.parse(text, { async: false }) as string;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
type MentionType = 'lesson' | 'exercise' | 'section';
|
||||
|
||||
/**
|
||||
* Mention format: @[Title](type:id)
|
||||
* Where type is "lesson", "exercise", or "section" and id is the content item ID.
|
||||
*/
|
||||
const RAW_MENTION_REGEX = /@\[([^\]]+)\]\((lesson|exercise|section):([a-zA-Z0-9_-]+)\)/gi;
|
||||
const HTML_LINK_MENTION_REGEX = /@?<a\b[^>]*href=(["'])(lesson|exercise|section):([a-zA-Z0-9_-]+)\1[^>]*>(.*?)<\/a>/gi;
|
||||
|
||||
function normalizeMentionType(type: string): MentionType | null {
|
||||
const normalizedType = type.toLowerCase();
|
||||
|
||||
if (normalizedType === 'lesson' || normalizedType === 'exercise' || normalizedType === 'section') {
|
||||
return normalizedType;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getMentionRoute(courseId: string, type: MentionType, id: string) {
|
||||
if (type === 'exercise') return `/courses/${courseId}/exercises/${id}`;
|
||||
if (type === 'section') return `/courses/${courseId}/lessons#section-${id}`;
|
||||
|
||||
return `/courses/${courseId}/lessons/${id}`;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function buildMentionLink(route: string, label: string, title: string) {
|
||||
return `<a href="${route}" data-mention-route="${route}" class="mention-link" title="${escapeHtml(title)}">${label}</a>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace AI lesson/exercise mentions with real in-app links.
|
||||
* Handles both raw mention syntax and markdown-generated anchor tags.
|
||||
*/
|
||||
export function renderMentions(html: string, courseId: string): string {
|
||||
const linksWithRoutes = html.replace(
|
||||
HTML_LINK_MENTION_REGEX,
|
||||
(match, _quote: string, type: string, id: string, label: string) => {
|
||||
const mentionType = normalizeMentionType(type);
|
||||
|
||||
if (!mentionType) return match;
|
||||
|
||||
const route = getMentionRoute(courseId, mentionType, id);
|
||||
return buildMentionLink(route, label, `${mentionType}: ${id}`);
|
||||
}
|
||||
);
|
||||
|
||||
return linksWithRoutes.replace(RAW_MENTION_REGEX, (match, title: string, type: string, id: string) => {
|
||||
const mentionType = normalizeMentionType(type);
|
||||
|
||||
if (!mentionType) return match;
|
||||
|
||||
const route = getMentionRoute(courseId, mentionType, id);
|
||||
return buildMentionLink(route, escapeHtml(title), `${mentionType}: ${title}`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
/** Controls whether the AI assistant right sidebar Sheet is open. */
|
||||
export const isAiAssistantOpen = writable(false);
|
||||
|
||||
export function openAiAssistant() {
|
||||
isAiAssistantOpen.set(true);
|
||||
}
|
||||
|
||||
export function closeAiAssistant() {
|
||||
isAiAssistantOpen.set(false);
|
||||
}
|
||||
|
||||
export function toggleAiAssistant() {
|
||||
isAiAssistantOpen.update((open) => !open);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
const TOOL_LABELS: Record<string, string> = {
|
||||
get_course_structure: 'Reading course structure',
|
||||
get_lesson_content: 'Reading lesson content',
|
||||
get_exercise_details: 'Reading exercise',
|
||||
create_section: 'Creating section',
|
||||
update_section: 'Updating section',
|
||||
create_lesson: 'Creating lesson',
|
||||
update_lesson: 'Updating lesson',
|
||||
update_lesson_content: 'Writing lesson content',
|
||||
create_exercise: 'Creating exercise',
|
||||
update_exercise: 'Updating exercise',
|
||||
add_questions: 'Adding questions',
|
||||
update_questions: 'Updating questions',
|
||||
reorder_content: 'Reordering content',
|
||||
update_course_landing_page: 'Updating landing page',
|
||||
check_course_go_live_readiness: 'Checking go-live readiness',
|
||||
go_live_course: 'Publishing course',
|
||||
generate_course_plan: 'Generating course plan'
|
||||
};
|
||||
|
||||
export function getToolLabel(toolName: string): string {
|
||||
return TOOL_LABELS[toolName] || toolName;
|
||||
}
|
||||
|
||||
export function getToolResultLabel(toolName: string, result: unknown): string {
|
||||
const r = result as Record<string, unknown>;
|
||||
|
||||
switch (toolName) {
|
||||
case 'create_section':
|
||||
return `Created section: ${r.title}`;
|
||||
case 'update_section':
|
||||
return `Updated section: ${r.title}`;
|
||||
case 'create_lesson':
|
||||
return `Created lesson: ${r.title}`;
|
||||
case 'update_lesson':
|
||||
return `Updated lesson: ${r.title}`;
|
||||
case 'update_lesson_content':
|
||||
return `Wrote ${r.contentLength} chars`;
|
||||
case 'create_exercise':
|
||||
return `Created exercise with ${r.questionCount} questions`;
|
||||
case 'update_exercise':
|
||||
return `Updated exercise: ${r.title}`;
|
||||
case 'add_questions':
|
||||
return `Added ${r.addedCount} questions`;
|
||||
case 'update_questions':
|
||||
return `Updated ${r.updatedCount} questions`;
|
||||
case 'get_course_structure':
|
||||
return 'Course structure loaded';
|
||||
case 'get_lesson_content':
|
||||
return 'Lesson content loaded';
|
||||
case 'get_exercise_details':
|
||||
return 'Exercise loaded';
|
||||
case 'reorder_content':
|
||||
return 'Content reordered';
|
||||
case 'update_course_landing_page':
|
||||
return `Updated landing page: ${r.title}`;
|
||||
case 'check_course_go_live_readiness': {
|
||||
const blockerCount = Array.isArray(r.blockers) ? r.blockers.length : 0;
|
||||
const warningCount = Array.isArray(r.warnings) ? r.warnings.length : 0;
|
||||
|
||||
return blockerCount === 0
|
||||
? `Ready to go live${warningCount > 0 ? ` with ${warningCount} warnings` : ''}`
|
||||
: `${blockerCount} blockers before go-live`;
|
||||
}
|
||||
case 'go_live_course':
|
||||
return r.isPublished ? `Published course: ${r.title}` : 'Course was not published';
|
||||
case 'generate_course_plan':
|
||||
return 'Course plan generated';
|
||||
default:
|
||||
return 'Done';
|
||||
}
|
||||
}
|
||||
|
||||
export const MUTATION_TOOLS = [
|
||||
'create_section',
|
||||
'update_section',
|
||||
'create_lesson',
|
||||
'update_lesson',
|
||||
'update_lesson_content',
|
||||
'create_exercise',
|
||||
'update_exercise',
|
||||
'add_questions',
|
||||
'update_questions',
|
||||
'reorder_content',
|
||||
'update_course_landing_page',
|
||||
'go_live_course'
|
||||
];
|
||||
|
||||
export interface ProgressStep {
|
||||
label: string;
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
indent?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { ProgressStep } from './tool-labels';
|
||||
|
||||
export interface AgentToolPart {
|
||||
type: string;
|
||||
state?: string;
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
errorText?: string;
|
||||
toolInvocation?: {
|
||||
toolName?: string;
|
||||
result?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export function isAgentToolPart(part: Record<string, unknown>): part is AgentToolPart {
|
||||
if (part.type === 'tool-invocation') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return typeof part.type === 'string' && part.type.startsWith('tool-');
|
||||
}
|
||||
|
||||
export function getAgentToolName(part: AgentToolPart): string | null {
|
||||
if (part.type === 'tool-invocation') {
|
||||
return part.toolInvocation?.toolName ?? null;
|
||||
}
|
||||
|
||||
if (!part.type.startsWith('tool-')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return part.type.slice(5);
|
||||
}
|
||||
|
||||
export function getAgentToolResult(part: AgentToolPart): unknown {
|
||||
if (part.type === 'tool-invocation') {
|
||||
return part.toolInvocation?.result;
|
||||
}
|
||||
|
||||
return part.output;
|
||||
}
|
||||
|
||||
export function getAgentToolStatus(part: AgentToolPart): ProgressStep['status'] {
|
||||
if (part.state === 'result' || part.state === 'output-available') {
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
if (part.state === 'output-error') {
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
if (
|
||||
part.state === 'call' ||
|
||||
part.state === 'partial-call' ||
|
||||
part.state === 'input-streaming' ||
|
||||
part.state === 'input-available'
|
||||
) {
|
||||
return 'in_progress';
|
||||
}
|
||||
|
||||
return 'pending';
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { UIMessage } from 'ai';
|
||||
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
|
||||
|
||||
export interface UploadedDocument {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AiAssistantMessageAttachment {
|
||||
documentId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AiAssistantMessageMetadata {
|
||||
attachment?: AiAssistantMessageAttachment;
|
||||
}
|
||||
|
||||
export type AiAssistantMessage = UIMessage<AiAssistantMessageMetadata>;
|
||||
|
||||
export type AgentStatusRequest = typeof classroomio.agent.status.$get;
|
||||
export type AgentStatusSuccess = Extract<InferResponseType<AgentStatusRequest>, { success: true }>;
|
||||
export type AgentStatusData = AgentStatusSuccess['data'];
|
||||
|
||||
export type AgentUsageRequest = typeof classroomio.agent.usage.$get;
|
||||
export type AgentUsageSuccess = Extract<InferResponseType<AgentUsageRequest>, { success: true }>;
|
||||
export type AgentUsageData = AgentUsageSuccess['data'];
|
||||
|
||||
export type AgentHistoryGetRequest = typeof classroomio.agent.history.$get;
|
||||
export type AgentHistoryGetSuccess = Extract<InferResponseType<AgentHistoryGetRequest>, { success: true }>;
|
||||
export type AgentHistoryData = AgentHistoryGetSuccess['data'];
|
||||
|
||||
export type AgentConversationSummary = AgentHistoryData[number];
|
||||
|
||||
export type AgentConversationRequest = (typeof classroomio.agent.history)[':conversationId']['$get'];
|
||||
export type AgentConversationSuccess = Extract<InferResponseType<AgentConversationRequest>, { success: true }>;
|
||||
export type AgentConversation = Omit<AgentConversationSuccess['data'], 'messages'> & {
|
||||
messages: AiAssistantMessage[];
|
||||
};
|
||||
|
||||
export type AgentConversationCreateRequest = typeof classroomio.agent.history.$post;
|
||||
export type AgentConversationCreateSuccess = Extract<
|
||||
InferResponseType<AgentConversationCreateRequest>,
|
||||
{ success: true }
|
||||
>;
|
||||
export type AgentConversationCreateData = AgentConversationCreateSuccess['data'];
|
||||
|
||||
export type AgentHistorySaveRequest = (typeof classroomio.agent.history)[':conversationId']['$put'];
|
||||
|
||||
export type AgentHistoryDeleteRequest = (typeof classroomio.agent.history)[':conversationId']['$delete'];
|
||||
@@ -127,38 +127,36 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if programs.length > 0}
|
||||
<div class="space-y-3">
|
||||
<Label class="text-sm font-medium">{$t('audience.import.program_access')}</Label>
|
||||
<RadioGroup.Root bind:value={programAccessMode} class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup.Item value="none" id="program-none" />
|
||||
<Label for="program-none" class="font-normal">{$t('audience.import.no_programs')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup.Item value="all" id="program-all" />
|
||||
<Label for="program-all" class="font-normal">{$t('audience.import.all_programs')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup.Item value="select" id="program-select" />
|
||||
<Label for="program-select" class="font-normal">{$t('audience.import.select_programs')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
<div class="space-y-3">
|
||||
<Label class="text-sm font-medium">{$t('audience.import.program_access')}</Label>
|
||||
<RadioGroup.Root bind:value={programAccessMode} class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup.Item value="none" id="program-none" />
|
||||
<Label for="program-none" class="font-normal">{$t('audience.import.no_programs')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup.Item value="all" id="program-all" />
|
||||
<Label for="program-all" class="font-normal">{$t('audience.import.all_programs')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup.Item value="select" id="program-select" />
|
||||
<Label for="program-select" class="font-normal">{$t('audience.import.select_programs')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
|
||||
{#if programAccessMode === 'select'}
|
||||
<MultiSelectList
|
||||
class="ml-6"
|
||||
listClass="max-h-40"
|
||||
heading={$t('audience.import.select_programs')}
|
||||
emptyMessage={$t('audience.import.select_programs_placeholder')}
|
||||
items={programs.map((p) => ({ id: p.id, label: p.name || p.id }))}
|
||||
isSelected={(id) => selectedProgramIds.has(id)}
|
||||
onToggle={toggleProgram}
|
||||
namePrefix="import-program"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if programAccessMode === 'select'}
|
||||
<MultiSelectList
|
||||
class="ml-6"
|
||||
listClass="max-h-40"
|
||||
heading={$t('audience.import.select_programs')}
|
||||
emptyMessage={$t('audience.import.select_programs_placeholder')}
|
||||
items={programs.map((p) => ({ id: p.id, label: p.name || p.id }))}
|
||||
isSelected={(id) => selectedProgramIds.has(id)}
|
||||
onToggle={toggleProgram}
|
||||
namePrefix="import-program"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- <CheckboxField name="send-email" label={$t('audience.import.send_email')} bind:checked={sendEmail} /> -->
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import type {
|
||||
CourseComplianceOverview,
|
||||
ExtendCourseComplianceRequest,
|
||||
GetCourseComplianceOverviewRequest,
|
||||
GetLearnerComplianceHistoryRequest,
|
||||
LearnerComplianceHistory,
|
||||
ResetCourseComplianceRequest,
|
||||
WaiveCourseComplianceRequest
|
||||
} from '../utils/types';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import {
|
||||
ZCourseComplianceExtend,
|
||||
ZCourseComplianceReset,
|
||||
ZCourseComplianceWaive,
|
||||
type TCourseComplianceExtend,
|
||||
type TCourseComplianceReset,
|
||||
type TCourseComplianceWaive
|
||||
} from '@cio/utils/validation/course';
|
||||
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
||||
|
||||
export class ComplianceApi extends BaseApiWithErrors {
|
||||
overview = $state<CourseComplianceOverview | null>(null);
|
||||
learnerHistory = $state<LearnerComplianceHistory | null>(null);
|
||||
|
||||
private overviewKey = $state<string | null>(null);
|
||||
private learnerHistoryKey = $state<string | null>(null);
|
||||
|
||||
async getOverview(courseId: string) {
|
||||
const result = await this.execute<GetCourseComplianceOverviewRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].compliance.$get({
|
||||
param: { courseId }
|
||||
}),
|
||||
logContext: 'fetching course compliance overview',
|
||||
onSuccess: (response) => {
|
||||
this.overview = response.data;
|
||||
this.overviewKey = courseId;
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('snackbar.course.compliance.overview_failed');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result?.data;
|
||||
}
|
||||
|
||||
async ensureOverview(courseId: string) {
|
||||
if (this.overviewKey === courseId && this.overview) {
|
||||
return this.overview;
|
||||
}
|
||||
|
||||
return this.getOverview(courseId);
|
||||
}
|
||||
|
||||
async getLearnerHistory(courseId: string, profileId: string) {
|
||||
const result = await this.execute<GetLearnerComplianceHistoryRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].compliance.learners[':profileId'].$get({
|
||||
param: { courseId, profileId }
|
||||
}),
|
||||
logContext: 'fetching learner compliance history',
|
||||
onSuccess: (response) => {
|
||||
this.learnerHistory = response.data;
|
||||
this.learnerHistoryKey = `${courseId}:${profileId}`;
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('snackbar.course.compliance.history_failed');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result?.data;
|
||||
}
|
||||
|
||||
async ensureLearnerHistory(courseId: string, profileId: string) {
|
||||
const requestKey = `${courseId}:${profileId}`;
|
||||
if (this.learnerHistoryKey === requestKey && this.learnerHistory) {
|
||||
return this.learnerHistory;
|
||||
}
|
||||
|
||||
return this.getLearnerHistory(courseId, profileId);
|
||||
}
|
||||
|
||||
async resetCourseCompliance(courseId: string, fields: TCourseComplianceReset) {
|
||||
const result = ZCourseComplianceReset.safeParse(fields);
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'course');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await this.execute<ResetCourseComplianceRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].compliance.reset.$post({
|
||||
param: { courseId },
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'resetting course compliance',
|
||||
onSuccess: () => {
|
||||
snackbar.success('snackbar.course.compliance.reset_success');
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('snackbar.course.compliance.reset_failed');
|
||||
return;
|
||||
}
|
||||
|
||||
if ('error' in result && 'field' in result && result.field) {
|
||||
this.errors[result.field] = result.error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (response?.data) {
|
||||
this.overviewKey = null;
|
||||
await this.getOverview(courseId);
|
||||
}
|
||||
|
||||
return response?.data;
|
||||
}
|
||||
|
||||
async extendCourseCompliance(courseId: string, fields: TCourseComplianceExtend) {
|
||||
const result = ZCourseComplianceExtend.safeParse(fields);
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'course');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await this.execute<ExtendCourseComplianceRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].compliance.extend.$post({
|
||||
param: { courseId },
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'extending course compliance',
|
||||
onSuccess: () => {
|
||||
snackbar.success('snackbar.course.compliance.extend_success');
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('snackbar.course.compliance.extend_failed');
|
||||
return;
|
||||
}
|
||||
|
||||
if ('error' in result && 'field' in result && result.field) {
|
||||
this.errors[result.field] = result.error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (response?.data) {
|
||||
this.overviewKey = null;
|
||||
await this.getOverview(courseId);
|
||||
}
|
||||
|
||||
return response?.data;
|
||||
}
|
||||
|
||||
async waiveCourseCompliance(courseId: string, fields: TCourseComplianceWaive) {
|
||||
const result = ZCourseComplianceWaive.safeParse(fields);
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'course');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await this.execute<WaiveCourseComplianceRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].compliance.waive.$post({
|
||||
param: { courseId },
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'waiving course compliance',
|
||||
onSuccess: () => {
|
||||
snackbar.success('snackbar.course.compliance.waive_success');
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('snackbar.course.compliance.waive_failed');
|
||||
return;
|
||||
}
|
||||
|
||||
if ('error' in result && 'field' in result && result.field) {
|
||||
this.errors[result.field] = result.error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (response?.data) {
|
||||
this.overviewKey = null;
|
||||
await this.getOverview(courseId);
|
||||
}
|
||||
|
||||
return response?.data;
|
||||
}
|
||||
|
||||
override reset() {
|
||||
super.reset();
|
||||
this.overview = null;
|
||||
this.learnerHistory = null;
|
||||
this.overviewKey = null;
|
||||
this.learnerHistoryKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const complianceApi = new ComplianceApi();
|
||||
@@ -26,9 +26,8 @@ export class ContentApi extends BaseApiWithErrors {
|
||||
this.errors = {};
|
||||
},
|
||||
onError: (error) => {
|
||||
if (typeof error === 'string') {
|
||||
snackbar.error('Failed to update content');
|
||||
}
|
||||
const errorMessage = typeof error === 'string' ? error : (error as any)?.error;
|
||||
snackbar.error(errorMessage || 'Failed to update content');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -51,9 +50,8 @@ export class ContentApi extends BaseApiWithErrors {
|
||||
this.errors = {};
|
||||
},
|
||||
onError: (error) => {
|
||||
if (typeof error === 'string') {
|
||||
snackbar.error('Failed to delete content');
|
||||
}
|
||||
const errorMessage = typeof error === 'string' ? error : (error as any)?.error;
|
||||
snackbar.error(errorMessage || 'Failed to delete content');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
} from '../utils/types';
|
||||
import type { TCourseType } from '@cio/db/types';
|
||||
import { ZCourseCreate, ZCourseUpdate, type TCourseUpdate } from '@cio/utils/validation/course/course';
|
||||
import { DEFAULT_COMPLIANCE_SETTINGS } from '../utils/compliance-utils';
|
||||
import { capturePosthogEvent } from '$lib/utils/services/posthog';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { get } from 'svelte/store';
|
||||
@@ -357,7 +358,8 @@ export class CourseApi extends BaseApiWithErrors {
|
||||
title: fields.title,
|
||||
description: fields.description,
|
||||
type: fields.type,
|
||||
organizationId: org.id
|
||||
organizationId: org.id,
|
||||
compliance: fields.type === 'COMPLIANCE' ? DEFAULT_COMPLIANCE_SETTINGS : undefined
|
||||
};
|
||||
|
||||
// Client-side validation
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './attendance.svelte';
|
||||
export * from './compliance.svelte';
|
||||
export * from './course-clone.svelte';
|
||||
export * from './course.svelte';
|
||||
export * from './courses.svelte';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
|
||||
import { copyCourseModal, deleteCourseModal } from '$features/course/utils/store';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
@@ -12,9 +13,10 @@
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
isPublished?: boolean;
|
||||
}
|
||||
|
||||
let { id, title, description }: Props = $props();
|
||||
let { id, title, description, isPublished = false }: Props = $props();
|
||||
|
||||
function redirect(url: string) {
|
||||
goto(resolve(url, {}));
|
||||
@@ -40,6 +42,10 @@
|
||||
$copyCourseModal.title = title;
|
||||
$copyCourseModal.description = description;
|
||||
}
|
||||
|
||||
function handleOpenCourseSite() {
|
||||
window.open(`/courses/${id}`, '_blank');
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
@@ -52,6 +58,13 @@
|
||||
</IconButton>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
{#if isPublished}
|
||||
<DropdownMenu.Item onclick={handleOpenCourseSite}>
|
||||
<ExternalLinkIcon size={16} class="mr-2" />
|
||||
{$t('courses.course_card.context_menu.open_course_site')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
{/if}
|
||||
<DropdownMenu.Item onclick={handleCloneCourse}>
|
||||
{$t('courses.course_card.context_menu.clone')}
|
||||
</DropdownMenu.Item>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import type { Component, Snippet } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { CourseCard, DEFAULT_COURSE_BANNER_IMAGE } from '@cio/ui';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
import CircleDotIcon from '@lucide/svelte/icons/circle-dot';
|
||||
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check';
|
||||
import TrendingUpIcon from '@lucide/svelte/icons/trending-up';
|
||||
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
|
||||
|
||||
@@ -17,6 +19,11 @@
|
||||
import { calcCourseDiscount } from '$lib/utils/functions/course';
|
||||
import getCurrencyFormatter from '$lib/utils/functions/getCurrencyFormatter';
|
||||
import { calcCourseProgress, calcProgressRate } from '$features/course/utils/functions';
|
||||
import {
|
||||
getStudentCourseComplianceDate,
|
||||
getStudentCourseComplianceStatusKey,
|
||||
getStudentCourseComplianceStatusVariant
|
||||
} from '$features/course/utils/compliance-utils';
|
||||
import CardDropdown from './card-dropdown.svelte';
|
||||
import CoursePublishBadge from './course-publish-badge.svelte';
|
||||
import CourseTagsOverflow from './course-tags-overflow.svelte';
|
||||
@@ -117,6 +124,12 @@
|
||||
icon: UserIcon,
|
||||
iconStyle: 'custom ui:text-primary'
|
||||
},
|
||||
['COMPLIANCE']: {
|
||||
style: '',
|
||||
label: $t('course.navItem.settings.compliance'),
|
||||
icon: ShieldCheckIcon,
|
||||
iconStyle: 'custom text-emerald-700'
|
||||
},
|
||||
SPECIALIZATION: {
|
||||
style: '',
|
||||
label: $t('specialization.course_tag'),
|
||||
@@ -126,9 +139,21 @@
|
||||
|
||||
let cost = $derived(calcCourseDiscount(pricingData.discount, pricingData.cost ?? 0, !!pricingData.showDiscount));
|
||||
|
||||
let courseUrl = $derived(
|
||||
href ?? (isOnLandingPage || isExplore ? `/course/${slug}` : `/courses/${id}${isLMS ? '/lessons?next=true' : ''}`)
|
||||
);
|
||||
let courseUrl = $derived.by(() => {
|
||||
if (href) {
|
||||
return href;
|
||||
}
|
||||
|
||||
if (isOnLandingPage || isExplore) {
|
||||
if (!slug) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `/course/${slug}`;
|
||||
}
|
||||
|
||||
return `/courses/${id}${isLMS ? '/lessons?next=true' : ''}`;
|
||||
});
|
||||
|
||||
const typeBadge = $derived(
|
||||
type && COURSE_TAG[type]
|
||||
@@ -139,9 +164,46 @@
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
|
||||
const complianceStatusKey = $derived(
|
||||
isLMS && type === 'COMPLIANCE' && !isExplore
|
||||
? getStudentCourseComplianceStatusKey(course as UserEnrolledCourses[number])
|
||||
: null
|
||||
);
|
||||
const complianceStatusVariant = $derived(
|
||||
isLMS && type === 'COMPLIANCE' && !isExplore
|
||||
? getStudentCourseComplianceStatusVariant(course as UserEnrolledCourses[number])
|
||||
: 'outline'
|
||||
);
|
||||
const complianceDate = $derived(
|
||||
isLMS && type === 'COMPLIANCE' && !isExplore
|
||||
? getStudentCourseComplianceDate(course as UserEnrolledCourses[number])
|
||||
: null
|
||||
);
|
||||
|
||||
function formatDate(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
dateStyle: 'medium'
|
||||
}).format(date);
|
||||
}
|
||||
</script>
|
||||
|
||||
<CourseCard href={resolve(courseUrl, {})} {title} {description} {typeBadge} class="group relative">
|
||||
<CourseCard
|
||||
href={courseUrl ? resolve(courseUrl, {}) : undefined}
|
||||
{title}
|
||||
{description}
|
||||
{typeBadge}
|
||||
class="group relative"
|
||||
>
|
||||
{#snippet media()}
|
||||
<Image src={bannerImage} alt="Course banner image" className="w-full h-full rounded-sm object-cover" />
|
||||
{/snippet}
|
||||
@@ -150,7 +212,7 @@
|
||||
{#if actions}
|
||||
{@render actions()}
|
||||
{:else if !isLMS && !isOnLandingPage}
|
||||
<CardDropdown {id} {title} {description} />
|
||||
<CardDropdown {id} {title} {description} {isPublished} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
@@ -195,6 +257,22 @@
|
||||
<Progress value={progressRate} />
|
||||
<p class="ui:text-muted-foreground text-xs">{progressRate}%</p>
|
||||
</div>
|
||||
|
||||
{#if type === 'COMPLIANCE'}
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||
{#if complianceStatusKey}
|
||||
<Badge variant={complianceStatusVariant}>
|
||||
{$t(complianceStatusKey)}
|
||||
</Badge>
|
||||
{/if}
|
||||
|
||||
{#if complianceDate?.value}
|
||||
<p class="ui:text-muted-foreground text-xs">
|
||||
{$t(complianceDate.labelKey)}: {formatDate(complianceDate.value)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else}
|
||||
<CoursePublishBadge {isPublished} />
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
type CourseSortOrder
|
||||
} from '../utils/constants';
|
||||
import { Checkbox } from '@cio/ui/base/checkbox';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { SortPopover } from '$features/ui';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
@@ -15,7 +16,7 @@
|
||||
slug: string;
|
||||
name: string;
|
||||
color: string;
|
||||
courseCount: number;
|
||||
courseCount?: number;
|
||||
}
|
||||
|
||||
interface FilterTagGroup {
|
||||
@@ -34,8 +35,14 @@
|
||||
tagGroups?: FilterTagGroup[];
|
||||
sortOptions?: readonly SortOption[];
|
||||
isFiltering?: boolean;
|
||||
hasActiveFilters?: boolean;
|
||||
publishedStatus?: 'all' | 'published' | 'unpublished';
|
||||
courseType?: string;
|
||||
courseTypeOptions?: { value: string; label: string }[];
|
||||
onToggleTag?: (tagSlug: string, checked: boolean) => void;
|
||||
onClearFilters?: () => void | Promise<void>;
|
||||
onPublishedStatusChange?: (status: 'all' | 'published' | 'unpublished') => void;
|
||||
onCourseTypeChange?: (type: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -45,8 +52,14 @@
|
||||
tagGroups = [],
|
||||
sortOptions = COURSE_SORT_OPTIONS,
|
||||
isFiltering = false,
|
||||
hasActiveFilters: hasActiveFiltersOverride = undefined,
|
||||
publishedStatus = $bindable('all'),
|
||||
courseType = $bindable('all'),
|
||||
courseTypeOptions = [],
|
||||
onToggleTag = () => {},
|
||||
onClearFilters = () => {}
|
||||
onClearFilters = () => {},
|
||||
onPublishedStatusChange,
|
||||
onCourseTypeChange
|
||||
}: Props = $props();
|
||||
|
||||
const translatedSortOptions = $derived(
|
||||
@@ -54,12 +67,28 @@
|
||||
);
|
||||
|
||||
const hasActiveFilters = $derived(
|
||||
sortKey !== DEFAULT_COURSE_SORT || selectedOrder !== DEFAULT_SORT_ORDER || selectedTags.length > 0
|
||||
hasActiveFiltersOverride !== undefined
|
||||
? hasActiveFiltersOverride
|
||||
: sortKey !== DEFAULT_COURSE_SORT ||
|
||||
selectedOrder !== DEFAULT_SORT_ORDER ||
|
||||
selectedTags.length > 0 ||
|
||||
publishedStatus !== 'all' ||
|
||||
courseType !== 'all'
|
||||
);
|
||||
|
||||
function isTagSelected(tagSlug: string) {
|
||||
return selectedTags.includes(tagSlug);
|
||||
}
|
||||
|
||||
function setPublishedStatus(status: 'all' | 'published' | 'unpublished') {
|
||||
publishedStatus = status;
|
||||
onPublishedStatusChange?.(status);
|
||||
}
|
||||
|
||||
function setCourseType(type: string) {
|
||||
courseType = type;
|
||||
onCourseTypeChange?.(type);
|
||||
}
|
||||
</script>
|
||||
|
||||
<SortPopover
|
||||
@@ -74,45 +103,95 @@
|
||||
>
|
||||
{#snippet additionalContent()}
|
||||
<div class="space-y-3">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">{$t('courses.tag_filters.tags')}</p>
|
||||
{#if tagGroups.length === 0}
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('courses.tag_filters.empty_tags')}</p>
|
||||
{:else}
|
||||
<div class="max-h-[320px] space-y-4 overflow-y-auto pr-1">
|
||||
{#each tagGroups as group (group.id)}
|
||||
{#if group.tags.length > 0}
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium">{group.name}</p>
|
||||
{#if group.description}
|
||||
<p class="ui:text-muted-foreground text-xs">{group.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
{#each group.tags as tag (tag.id)}
|
||||
<label
|
||||
class="hover:ui:bg-muted/30 flex cursor-pointer items-center justify-between rounded-md border px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={isTagSelected(tag.slug)}
|
||||
onCheckedChange={(checked) => onToggleTag(tag.slug, Boolean(checked))}
|
||||
/>
|
||||
<span
|
||||
class="inline-block h-2.5 w-2.5 rounded-full border"
|
||||
style={`background-color: ${tag.color}`}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="text-sm">{tag.name}</span>
|
||||
</div>
|
||||
<span class="ui:text-muted-foreground text-xs">{tag.courseCount}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
<div class="space-y-2">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">{$t('widgets.filter.status')}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={publishedStatus === 'all' ? 'secondary' : 'outline'}
|
||||
onclick={() => setPublishedStatus('all')}>{$t('widgets.filter.all')}</Button
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={publishedStatus === 'published' ? 'secondary' : 'outline'}
|
||||
onclick={() => setPublishedStatus('published')}>{$t('widgets.status.published')}</Button
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={publishedStatus === 'unpublished' ? 'secondary' : 'outline'}
|
||||
onclick={() => setPublishedStatus('unpublished')}>{$t('widgets.filter.unpublished')}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if courseTypeOptions.length > 0}
|
||||
<div class="space-y-2">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">{$t('widgets.filter.course_type')}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={courseType === 'all' ? 'secondary' : 'outline'}
|
||||
onclick={() => setCourseType('all')}>{$t('widgets.filter.all')}</Button
|
||||
>
|
||||
{#each courseTypeOptions as opt (opt.value)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={courseType === opt.value ? 'secondary' : 'outline'}
|
||||
onclick={() => setCourseType(opt.value)}>{opt.label}</Button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if tagGroups.length > 0}
|
||||
<div class="space-y-2">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">{$t('courses.tag_filters.tags')}</p>
|
||||
<div class="max-h-[320px] space-y-4 overflow-y-auto pr-1">
|
||||
{#each tagGroups as group (group.id)}
|
||||
{#if group.tags.length > 0}
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium">{group.name}</p>
|
||||
{#if group.description}
|
||||
<p class="ui:text-muted-foreground text-xs">{group.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
{#each group.tags as tag (tag.id)}
|
||||
<label
|
||||
class="hover:ui:bg-muted/30 flex cursor-pointer items-center justify-between rounded-md border px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={isTagSelected(tag.slug)}
|
||||
onCheckedChange={(checked) => onToggleTag(tag.slug, Boolean(checked))}
|
||||
/>
|
||||
<span
|
||||
class="inline-block h-2.5 w-2.5 rounded-full border"
|
||||
style={`background-color: ${tag.color}`}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="text-sm">{tag.name}</span>
|
||||
</div>
|
||||
{#if tag.courseCount}
|
||||
<span class="ui:text-muted-foreground text-xs">{tag.courseCount}</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else if tagGroups.length === 0 && courseTypeOptions.length === 0 && publishedStatus === undefined}
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('courses.tag_filters.empty_tags')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
@@ -10,10 +10,13 @@
|
||||
import * as Empty from '@cio/ui/base/empty';
|
||||
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
|
||||
import { isStudentExperience } from '$lib/utils/store/app';
|
||||
import SparklesIcon from '@lucide/svelte/icons/sparkles';
|
||||
import { setupProgressApi } from '$features/setup/api/setup-progress.svelte';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { openCoursePreview } from '$features/course/utils/course-preview';
|
||||
import { toggleAiAssistant } from '$features/ai-assistant/utils/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
import CoursePublishBadge from './course-publish-badge.svelte';
|
||||
|
||||
const siteName = $derived($currentOrg.siteName);
|
||||
@@ -56,6 +59,13 @@
|
||||
|
||||
<span class="grow"></span>
|
||||
|
||||
{#if !$isFreePlan}
|
||||
<Button variant="outline" size="sm" onclick={toggleAiAssistant}>
|
||||
<SparklesIcon size={14} />
|
||||
{$t('course.navItems.nav_ai_assistant')}
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<Button variant="outline" size="icon">
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
|
||||
import OrderModal from './order-modal.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import DeleteConfirmationModal from './delete-confirmation.svelte';
|
||||
@@ -57,7 +56,6 @@
|
||||
}: Props = $props();
|
||||
|
||||
let questionIdToDelete = $state(null);
|
||||
let isDeleting = $state(false);
|
||||
let errors = $derived($questionnaireValidation);
|
||||
const questionLabels = $derived(getExerciseQuestionLabels());
|
||||
|
||||
@@ -181,54 +179,12 @@
|
||||
return { ...q, questions: next };
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!courseApi.course?.id) return;
|
||||
isDeleting = true;
|
||||
|
||||
await exerciseApi.delete(courseApi.course.id, exerciseId);
|
||||
|
||||
if (exerciseApi.success) {
|
||||
courseApi.removeContentItem(exerciseId, ContentType.Exercise);
|
||||
goBack();
|
||||
}
|
||||
isDeleting = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<DeleteConfirmationModal onCancel={() => (questionIdToDelete = null)} onDelete={onFinalDeleteClicked} />
|
||||
|
||||
<OrderModal />
|
||||
|
||||
<Dialog.Root
|
||||
bind:open={shouldDeleteExercise}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) shouldDeleteExercise = false;
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="w-2/4">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.delete_modal')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<form onsubmit={preventDefault()}>
|
||||
<h1 class="text-xl dark:text-white">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.sure')}
|
||||
</h1>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button variant="outline" type="submit" onclick={() => (shouldDeleteExercise = false)}>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.no')}
|
||||
</Button>
|
||||
<Button disabled={isDeleting} onclick={handleDelete} loading={isDeleting}>
|
||||
{isDeleting
|
||||
? $t('course.navItem.lessons.exercises.all_exercises.edit_mode.deleting')
|
||||
: $t('course.navItem.lessons.exercises.all_exercises.edit_mode.yes')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<div class="mb-20 w-full">
|
||||
{#if Object.values(errors).length}
|
||||
<div class="mb-4 flex w-full justify-center">
|
||||
|
||||
@@ -29,10 +29,12 @@
|
||||
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { toggleConfetti } from '$features/ui/confetti/store';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import { getOrderedNavigableContent } from '$features/course/utils/content';
|
||||
import { isOrgStudent } from '$lib/utils/store/app';
|
||||
import { openCourseCompletionModal } from '$features/course/store/course-completion-modal';
|
||||
import { isSelfPacedLikeCourse } from '$features/course/utils/compliance-utils';
|
||||
import { toExerciseQuestionModel } from './question-type-utils';
|
||||
import { getExerciseQuestionLabels } from './question-labels';
|
||||
import axios from 'axios';
|
||||
@@ -152,6 +154,9 @@
|
||||
}
|
||||
courseApi.updateContentItem(exerciseId, ContentType.Exercise, { isComplete: true });
|
||||
|
||||
toggleConfetti();
|
||||
setTimeout(toggleConfetti, 3000);
|
||||
|
||||
const allContentItems = getOrderedNavigableContent(courseApi.course);
|
||||
const allComplete =
|
||||
$isOrgStudent && allContentItems.length > 0 && allContentItems.every((item) => item.isComplete);
|
||||
@@ -573,7 +578,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if courseApi.course?.type === 'SELF_PACED'}
|
||||
{:else if isSelfPacedLikeCourse(courseApi.course?.type)}
|
||||
<Badge
|
||||
variant="success"
|
||||
title={$t('course.navItem.lessons.exercises.all_exercises.view_mode.status_submitted')}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
import ContentRow from './content-row.svelte';
|
||||
import ContentActions from './content-actions.svelte';
|
||||
import { deleteContent, saveContent, toggleLock } from './content-action-helpers';
|
||||
import { isSelfPacedLikeCourse } from '$features/course/utils/compliance-utils';
|
||||
|
||||
const flipDurationMs = 300;
|
||||
|
||||
@@ -45,7 +46,7 @@
|
||||
const contentData = $derived(getCourseContent(courseApi.course));
|
||||
const isStudentView = $derived($isOrgStudent === true);
|
||||
const isLiveCourse = $derived(courseApi.course?.type === 'LIVE_CLASS');
|
||||
const isSelfPacedCourse = $derived(courseApi.course?.type === 'SELF_PACED');
|
||||
const isSelfPacedCourse = $derived(isSelfPacedLikeCourse(courseApi.course?.type));
|
||||
const metaChipClass =
|
||||
'inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-1 text-[10px] text-gray-700 dark:bg-neutral-700 dark:text-gray-200';
|
||||
|
||||
@@ -262,7 +263,7 @@
|
||||
/>
|
||||
|
||||
<section
|
||||
class="mx-auto w-full p-3 lg:w-11/12 lg:px-4 {reorder ? 'cursor-grab active:cursor-grabbing' : ''}"
|
||||
class="mx-auto w-full p-3 lg:w-11/12 {reorder ? 'cursor-grab active:cursor-grabbing' : ''}"
|
||||
use:dndzone={{
|
||||
items: contentItems,
|
||||
flipDurationMs,
|
||||
|
||||
+1
-3
@@ -63,9 +63,7 @@
|
||||
const showMarkComplete = $derived(!!lessonId && !exerciseId);
|
||||
|
||||
const currentLessonItem = $derived(lessonId ? lessonItems.find((l) => l.id === lessonId) : null);
|
||||
const isLessonLocked = $derived(
|
||||
$isOrgStudent && currentLessonItem && !(currentLessonItem.isUnlocked ?? false)
|
||||
);
|
||||
const isLessonLocked = $derived($isOrgStudent && currentLessonItem && !(currentLessonItem.isUnlocked ?? false));
|
||||
const isMarkCompleteDisabled = $derived(isMarkingComplete || isLessonLocked || isLessonComplete);
|
||||
|
||||
async function markLessonComplete(currentLessonId: string) {
|
||||
|
||||
+5
-3
@@ -283,6 +283,7 @@
|
||||
const wasDeleted = await contentApi.deleteContent(courseId, { sectionId: deletingData.id });
|
||||
if (wasDeleted) {
|
||||
removeSection(deletingData.id);
|
||||
snackbar.success('snackbar.lessons.section_deleted');
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -438,7 +439,7 @@
|
||||
<DeleteLessonConfirmation bind:openDeleteModal {deleteLesson} />
|
||||
|
||||
<section
|
||||
class="mx-auto w-full p-3 lg:w-11/12 lg:px-4"
|
||||
class="mx-auto w-full p-3 lg:w-11/12"
|
||||
use:dndzone={{
|
||||
items: sections,
|
||||
flipDurationMs,
|
||||
@@ -462,7 +463,8 @@
|
||||
: `${$t('course.navItem.lessons.add_lesson.unlock')} ${$t('course.navItem.lessons.add_lesson.all')}`
|
||||
: undefined}
|
||||
<div
|
||||
class="border-border m-auto mb-3 max-w-xl rounded-md border transition dark:bg-neutral-800"
|
||||
id={isUngrouped ? undefined : `section-${section.id}`}
|
||||
class="border-border m-auto mb-3 max-w-xl scroll-mt-24 rounded-md border transition dark:bg-neutral-800"
|
||||
style={reorder ? 'border: 2px #1d4ed8 solid; border-style: dashed; cursor: grab;' : ''}
|
||||
>
|
||||
<SectionHeader
|
||||
@@ -518,7 +520,7 @@
|
||||
{@const actionDisabled = Boolean($contentEditingStore && !isEditingItem)}
|
||||
|
||||
<div
|
||||
class="mb-2 flex min-h-[50px] max-w-xl items-start justify-between rounded-md border border-gray-200 px-3 py-1 transition"
|
||||
class="ui:border mb-2 flex min-h-[50px] max-w-xl items-start justify-between rounded-md px-3 py-1 transition"
|
||||
style={reorder ? 'border: 2px #1d4ed8 solid; border-style: dashed; cursor: grab;' : ''}
|
||||
animate:flip={{ duration: flipDurationMs }}
|
||||
>
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
{:else}
|
||||
<div class="flex w-4/6 items-center gap-2">
|
||||
<CourseContentIcon type={ContentType.Section} size={16} />
|
||||
<p class="font-semibold">{section.title}</p>
|
||||
<p class="text-sm">{section.title}</p>
|
||||
<ContentCountBadges lessons={sectionCount.lessons} exercises={sectionCount.exercises} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user