feat: widgets and ai chat
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
---
|
||||
name: frontend-design
|
||||
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
|
||||
license: Complete terms in LICENSE.txt
|
||||
---
|
||||
|
||||
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
|
||||
|
||||
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
|
||||
|
||||
## Design Thinking
|
||||
|
||||
Before coding, understand the context and commit to a BOLD aesthetic direction:
|
||||
- **Purpose**: What problem does this interface solve? Who uses it?
|
||||
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
|
||||
- **Constraints**: Technical requirements (framework, performance, accessibility).
|
||||
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
|
||||
|
||||
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
|
||||
|
||||
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
|
||||
- Production-grade and functional
|
||||
- Visually striking and memorable
|
||||
- Cohesive with a clear aesthetic point-of-view
|
||||
- Meticulously refined in every detail
|
||||
|
||||
## Internal Component Library
|
||||
|
||||
**ALWAYS prioritise components from `packages/ui` before reaching for raw HTML elements or third-party UI libraries.**
|
||||
|
||||
Import using the `@cio/ui` package name. Examples:
|
||||
- `import { Button } from '@cio/ui/base/button'`
|
||||
- `import { Chip } from '@cio/ui/custom/chip'`
|
||||
- `import { cn } from '@cio/ui/tools'`
|
||||
- `import { BackButton } from '@cio/ui'` (re-exported root for some components)
|
||||
|
||||
Available base components (covers the majority of common UI needs):
|
||||
`accordion`, `alert`, `avatar`, `badge`, `breadcrumb`, `button`, `button-group`, `card`, `chart`, `checkbox`, `collapsible`, `command`, `copy-button`, `dark-mode`, `dialog`, `drawer`, `dropdown-menu`, `empty`, `field`, `hover-card`, `input`, `input-group`, `item`, `kbd`, `label`, `menubar`, `navigation-menu`, `page`, `pagination`, `password`, `popover`, `progress`, `radio-group`, `select`, `separator`, `sheet`, `sidebar`, `skeleton`, `sonner`, `spinner`, `switch`, `table`, `tabs`, `textarea`, `toggle`, `toggle-group`, `tooltip`
|
||||
|
||||
Available custom components (domain-specific, higher-level):
|
||||
`animation`, `back-button`, `chat-textarea`, `checkbox-field`, `checkbox-option-card`, `chip`, `code`, `course-card`, `document-card`, `domain-input`, `editor`, `empty`, `exercise-question`, `file-drop-zone`, `icon-button`, `image-cropper`, `input-field`, `media-player`, `mention-popover`, `moving-icons`, `multi-select-list`, `newsfeed-reactions`, `org-landing-page`, `password`, `pricing-card`, `pricing-toggle`, `question-type-picker`, `radio-item`, `radio-option-card`, `resource-list-row`, `safe-html-content`, `search`, `simple-logo-nav`, `textarea-field`, `underline-tabs`, `user-avatar`, `widget-layout-preview`, `youtube-link-form`
|
||||
|
||||
Only build a component from scratch when none of the above is a reasonable fit. When extending an existing component, prefer composition over modification of the source file.
|
||||
|
||||
## Frontend Aesthetics Guidelines
|
||||
|
||||
Focus on:
|
||||
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
|
||||
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
|
||||
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
|
||||
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
|
||||
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
|
||||
|
||||
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
|
||||
|
||||
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
|
||||
|
||||
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
|
||||
|
||||
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
|
||||
@@ -107,6 +107,13 @@ PRIVATE_APP_SUBDOMAINS=app
|
||||
# Unsplash image search
|
||||
UNSPLASH_API_KEY=
|
||||
|
||||
# AI Assistant — set at least one provider key to enable the in-course chat.
|
||||
# The dashboard model picker exposes Gemini 3 Flash (Google) and GPT-4o (OpenAI).
|
||||
# Anthropic is supported in code but not surfaced in the picker UI.
|
||||
OPENAI_API_KEY=
|
||||
GOOGLE_API_KEY=
|
||||
ANTHROPIC_API_KEY=
|
||||
|
||||
# Enterprise license (SSO, token-auth, no-tracking)
|
||||
LICENSE_KEY=
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ apps/api/static
|
||||
|
||||
pnpm-lock.yaml
|
||||
**/*.md
|
||||
**/*.html
|
||||
|
||||
|
||||
packages/ui/src/output.css
|
||||
|
||||
Vendored
+6
@@ -46,5 +46,11 @@
|
||||
"**/.turbo": true,
|
||||
"**/coverage": true
|
||||
// "pnpm-lock.yaml": true
|
||||
},
|
||||
"chat.tools.terminal.autoApprove": {
|
||||
"/^python3 /tmp/write_editor\\.py$/": {
|
||||
"approve": true,
|
||||
"matchCommandLine": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ Use this file to keep a single prioritized view of all PRDs.
|
||||
| - | 1 | `prd/ai-course-assistant` | todo |
|
||||
| - | 2 | `prd/compliance-training-platform` | todo |
|
||||
| - | 4 | `prd/course-content-search` | todo |
|
||||
| - | 6 | `prd/course-widget-embed` | todo |
|
||||
| - | 6 | `prd/course-widget-embed` | in-progress (core API, dashboard, public runtime, and embed build flow done; iframe preview parity, Shadow DOM, tests, and rollout hardening left) |
|
||||
| - | 9 | `prd/onboarding-ai-bootstrap` | todo |
|
||||
| - | 10 | `prd/pricing-improvements` | todo |
|
||||
| - | 11 | `prd/public-api` | todo |
|
||||
|
||||
@@ -179,6 +179,23 @@ The repository also contains shared packages under `packages/` (for example `pac
|
||||
|
||||
To learn more about how to login with a dummy account, [go here.](https://classroomio.com/docs/contributor-guides/demo-accounts)
|
||||
|
||||
### Enabling the AI Course Assistant
|
||||
|
||||
The in-course AI chat (course authoring, plan generation, lesson edits) is **disabled by default**. Enable it by setting at least one provider API key in `apps/api/.env` (or the root `.env` for the Docker stack):
|
||||
|
||||
```bash
|
||||
# Pick one or more — the dashboard model picker exposes Gemini 2.5 Flash and GPT-4o.
|
||||
OPENAI_API_KEY=sk-... # enables GPT-4o
|
||||
GOOGLE_API_KEY=AIza... # enables Gemini 2.5 Flash (default model in the picker)
|
||||
ANTHROPIC_API_KEY=sk-ant-... # supported in code; not currently in the picker UI
|
||||
```
|
||||
|
||||
Notes:
|
||||
- The `GET /agent/status` endpoint flips to `enabled: true` as soon as any of those keys is set, which is what the dashboard checks before showing the AI button on a course.
|
||||
- Each chat request sends the user-selected `model` (persisted in `localStorage` as `classroomio-ai-chat-model`). The API resolves the provider for that model (`packages/utils/src/agent-models`) and returns 503 `AI_NOT_CONFIGURED` if that provider's key is missing.
|
||||
- Optional Tinybird observability: set `TINYBIRD_TOKEN` (and optionally `TINYBIRD_BASE_URL`) in `apps/api/.env`. Events are silently skipped when the token is absent.
|
||||
- More detail on architecture, tools, and routes lives in [`prd/ai-course-assistant/README.md`](prd/ai-course-assistant/README.md).
|
||||
|
||||
### Docker Compose (Full Stack)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -41,3 +41,15 @@ LICENSE_KEY=""
|
||||
# SSO (Optional)
|
||||
# For enterprise SSO with SAML (future support)
|
||||
# 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(
|
||||
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,7 +127,6 @@
|
||||
{/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">
|
||||
@@ -158,7 +157,6 @@
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- <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,10 +103,55 @@
|
||||
>
|
||||
{#snippet additionalContent()}
|
||||
<div class="space-y-3">
|
||||
<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>
|
||||
{#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}
|
||||
@@ -105,7 +179,9 @@
|
||||
></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>
|
||||
@@ -113,6 +189,9 @@
|
||||
{/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}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user