chore: ai, public course and other ui improvements
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: AI assistant PRD
|
||||
overview: Write a new PRD from scratch for the AI Course Assistant feature, replacing the existing placeholder at prd/ai-course-assistant/README.md. The PRD will document a hybrid execution model, configurable LLM providers, streaming chat, and the full package/API/UI architecture.
|
||||
overview: Write a new PRD from scratch for the AI Course Assistant feature, replacing the existing placeholder at prd/ai-course-assistant [DONE]/README.md. The PRD will document a hybrid execution model, configurable LLM providers, streaming chat, and the full package/API/UI architecture.
|
||||
todos:
|
||||
- id: write-prd
|
||||
content: "Write the full PRD markdown file at prd/ai-course-assistant/README.md with all sections: purpose, architecture, execution model, package design, tools, safeguards, UI spec, self-host gating, streaming, API routes, and implementation phases"
|
||||
content: "Write the full PRD markdown file at prd/ai-course-assistant [DONE]/README.md with all sections: purpose, architecture, execution model, package design, tools, safeguards, UI spec, self-host gating, streaming, API routes, and implementation phases"
|
||||
status: pending
|
||||
isProject: false
|
||||
---
|
||||
@@ -12,7 +12,7 @@ isProject: false
|
||||
|
||||
## What We're Writing
|
||||
|
||||
Replace [prd/ai-course-assistant/README.md](prd/ai-course-assistant/README.md) with a comprehensive PRD for an AI assistant embedded in the course UI. The PRD will be a single markdown file covering architecture, UI, safeguards, package design, and implementation phases.
|
||||
Replace [prd/ai-course-assistant [DONE]/README.md](prd/ai-course-assistant [DONE]/README.md) with a comprehensive PRD for an AI assistant embedded in the course UI. The PRD will be a single markdown file covering architecture, UI, safeguards, package design, and implementation phases.
|
||||
|
||||
## Key Architectural Decisions
|
||||
|
||||
@@ -612,7 +612,7 @@ Registered in [apps/api/src/app.ts](apps/api/src/app.ts) as `.route('/agent', ag
|
||||
|
||||
**New files:**
|
||||
|
||||
- `prd/ai-course-assistant/README.md` — this PRD (replace existing)
|
||||
- `prd/ai-course-assistant [DONE]/README.md` — this PRD (replace existing)
|
||||
|
||||
**Files referenced (not modified in PRD phase):**
|
||||
|
||||
|
||||
@@ -0,0 +1,942 @@
|
||||
# PRD: ClassroomIO Labs
|
||||
|
||||
**Status:** Draft
|
||||
**Author:** Rotimi Best
|
||||
**Date:** 2026-05-03
|
||||
**Version:** 1.0
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Labs is a new lesson content block type that allows teachers to embed interactive, sandboxed mini-applications directly inside a lesson. Unlike quizzes — which test recall through structured questions — Labs are experiential: students practice, simulate, explore, or make decisions inside a purpose-built interactive environment.
|
||||
|
||||
Each Lab is a single compiled Svelte application hosted as a standalone HTML page and embedded via `<iframe>`. Labs communicate with ClassroomIO through a strict `postMessage` interface. They have no network access. They support two modes: **Teacher Mode** (configure the experience) and **Student Mode** (play the experience).
|
||||
|
||||
Labs are contributed by developers to an open-source marketplace repository and discovered through an in-editor picker modal.
|
||||
|
||||
---
|
||||
|
||||
## 2. Problem Statement
|
||||
|
||||
ClassroomIO is building for compliance training and certification programs. These programs need learners to not just understand content but to demonstrate judgment under realistic conditions — recognizing a phishing email, following an incident response playbook, correctly classifying sensitive data.
|
||||
|
||||
Today, the only interactive tool available to teachers is the quiz. Quizzes test recall but cannot simulate decision-making under pressure, multi-step scenarios, or contextual judgment. There is also no mechanism for the ClassroomIO ecosystem to grow through external developer contributions.
|
||||
|
||||
This leaves a critical pedagogical gap: compliance certifications require learners to practice realistic scenarios, and teachers have no way to deliver that inside a lesson.
|
||||
|
||||
---
|
||||
|
||||
## 3. Goals
|
||||
|
||||
1. **G1** — Give teachers a way to embed interactive, scenario-based activities directly in lessons alongside existing content blocks (video, notes, slides).
|
||||
2. **G2** — Make Labs completable and trackable — completion state and score feed into lesson progress and certification records.
|
||||
3. **G3** — Enable an open-source developer ecosystem where contributors can publish Labs to a public marketplace.
|
||||
4. **G4** — Keep Labs sandboxed and secure — no network access, no access to student PII, no access to ClassroomIO internals.
|
||||
5. **G5** — Support a teacher configuration model so a single Lab can be deployed differently across different courses or compliance contexts.
|
||||
|
||||
---
|
||||
|
||||
## 4. Non-Goals
|
||||
|
||||
- **Labs are not quizzes.** Labs do not replace the existing quiz block. They are complementary.
|
||||
- **Labs are not full course modules.** A Lab is a focused, time-bounded activity (typically 2–10 minutes). Multi-hour simulations are out of scope.
|
||||
- **Labs do not have network access.** External API calls from within a Lab are explicitly prohibited.
|
||||
- **Labs are not AI-generated on the fly.** The AI assistant may recommend which Lab to use, but it does not generate Lab code at runtime.
|
||||
- **Labs are not native mobile apps.** The initial release targets web only.
|
||||
- **The Labs marketplace is not monetized in v1.** All Labs are free and open source.
|
||||
|
||||
---
|
||||
|
||||
## 5. User Personas
|
||||
|
||||
### Teacher / Course Creator
|
||||
Builds lessons. Wants to give students realistic practice. Does not write code. Expects to pick a Lab from a list, configure it through a form, and have it appear in the lesson.
|
||||
|
||||
### Student / Learner
|
||||
Takes lessons as part of a certification program. Expects clear instructions, responsive interactions, and immediate feedback. Needs to complete the Lab to progress through the lesson.
|
||||
|
||||
### Lab Developer / Contributor
|
||||
A developer (internal or external) who builds a Lab app as a single Svelte file and submits it to the marketplace repository via pull request. Needs a clear SDK, local dev harness, and contribution guidelines.
|
||||
|
||||
### Organization Admin / Compliance Manager
|
||||
Monitors certification completion. Needs assurance that Lab completions are recorded in audit logs alongside quiz scores.
|
||||
|
||||
---
|
||||
|
||||
## 6. User Stories
|
||||
|
||||
### Teacher
|
||||
- As a teacher, I can add a Lab block to any lesson so that students have an interactive activity alongside my written content.
|
||||
- As a teacher, I can pick a Lab from a categorized list (by topic, compliance type, difficulty) so I can find the right one quickly.
|
||||
- As a teacher, I can configure the Lab (difficulty, number of rounds, pass threshold, scenario set) through a form so the experience fits my course context.
|
||||
- As a teacher, I can preview the Lab in both Teacher Mode and Student Mode before publishing so I know exactly what students will see.
|
||||
- As a teacher, I can set a minimum pass score for the Lab so students must demonstrate competence before marking the lesson complete.
|
||||
- As a teacher, I can reorder the Lab block within the lesson just like any other content block.
|
||||
|
||||
### Student
|
||||
- As a student, I can see a Lab embedded inline in my lesson and interact with it without leaving the lesson page.
|
||||
- As a student, I receive clear instructions before starting the Lab so I know what I am expected to do.
|
||||
- As a student, I receive immediate feedback during the Lab so I can learn from my choices in the moment.
|
||||
- As a student, I can see my result (score, pass/fail, summary) when the Lab ends so I know how I performed.
|
||||
- As a student, if I fail a Lab that has a required pass score, I can retry it so I have another chance to pass.
|
||||
- As a student, my Lab completion is reflected in my overall lesson progress so I can track my certification status.
|
||||
|
||||
### Lab Developer
|
||||
- As a developer, I can scaffold a new Lab using a CLI command so I can start building without boilerplate.
|
||||
- As a developer, I can run a local dev harness that simulates the `postMessage` init flow so I can develop the Lab in isolation.
|
||||
- As a developer, I can define a config schema in `manifest.json` so ClassroomIO auto-generates the teacher configuration form for my Lab.
|
||||
- As a developer, I can submit my Lab as a pull request to the `classroomio-labs` repository so it appears in the marketplace after review.
|
||||
|
||||
### Admin
|
||||
- As a compliance manager, I can view Lab completion records per student in the organization dashboard so I can audit certification evidence.
|
||||
- As a compliance manager, I can see the Lab version a student completed so I know if they need to redo it after an update.
|
||||
|
||||
---
|
||||
|
||||
## 7. Feature Requirements
|
||||
|
||||
### 7.1 Lab Block in Lesson Editor
|
||||
|
||||
- The lesson content block picker gains a new item: **Lab**. It sits alongside Video, Note, and Slide in the picker list.
|
||||
- Selecting "Lab" opens the Lab Picker Modal (see 7.2).
|
||||
- Once a Lab is chosen and configured, the block renders in the lesson editor as:
|
||||
- The Lab's name and preview thumbnail.
|
||||
- A **Configure** button to reopen the configuration form.
|
||||
- A **Preview** button to see the live iframe in either Teacher or Student mode.
|
||||
- A remove/delete button.
|
||||
- The block is orderable via drag-and-drop like all other blocks.
|
||||
- The block stores: `{ labSlug, labVersion, config: Record<string, unknown> }`.
|
||||
|
||||
### 7.2 Lab Picker Modal
|
||||
|
||||
- Triggered when a teacher selects "Lab" from the block picker.
|
||||
- Displays all available Labs from the marketplace.
|
||||
- Each Lab card shows:
|
||||
- Name
|
||||
- Short description (≤ 120 characters)
|
||||
- Preview thumbnail / animated GIF
|
||||
- Tags (e.g. `cybersecurity`, `compliance`, `finance`, `health-safety`)
|
||||
- Author
|
||||
- Estimated completion time
|
||||
- Teachers can filter by tag and search by name.
|
||||
- Selecting a Lab proceeds to the Configuration step within the same modal.
|
||||
- Configuration step:
|
||||
- Renders the form derived from the Lab's `configSchema`.
|
||||
- Shows a live iframe preview of the Lab in Teacher Mode reflecting the current config values.
|
||||
- Has a Save button that closes the modal and inserts the block.
|
||||
|
||||
### 7.3 Teacher Mode
|
||||
|
||||
- When the iframe is loaded with `mode: 'teacher'`, the Lab renders its own configuration UI.
|
||||
- The Lab's config UI is entirely self-contained inside the iframe. ClassroomIO does not render config fields directly — it drives the form via the `configSchema` in the picker modal (see 7.2), but the Lab itself also renders a config preview.
|
||||
- When the teacher changes config in the modal form, ClassroomIO sends a fresh `init` message to the iframe so the Lab can update its preview in real time.
|
||||
- When the teacher switches to "Student Preview", the iframe reloads with `mode: 'student'` and the current config frozen in.
|
||||
|
||||
### 7.4 Student Mode
|
||||
|
||||
- The Lab iframe is embedded inline in the lesson page with `mode: 'student'` and the teacher's saved config.
|
||||
- Before the Lab starts, an instruction overlay is shown (either from the Lab itself or from ClassroomIO wrapping the iframe) covering:
|
||||
- Lab name
|
||||
- Objective / what the student needs to do
|
||||
- Estimated time
|
||||
- Pass score (if configured)
|
||||
- A **Start** button
|
||||
- The Lab is fullscreen-expandable via a button on the iframe wrapper.
|
||||
- While the student is in the Lab, a progress indicator (if the Lab emits `progress` events) shows on the iframe wrapper.
|
||||
- When the Lab emits `complete`, ClassroomIO:
|
||||
- Records the result (score, passed, timestamp, labVersion, config hash).
|
||||
- Displays a result overlay on top of the iframe showing: score, pass/fail badge, and a brief summary from the Lab.
|
||||
- If the student passed (or no pass threshold was set): marks the Lab block as complete in lesson progress.
|
||||
- If the student failed and retries are allowed: shows a Retry button that reloads the iframe.
|
||||
- A completed Lab block shows a green check in the lesson.
|
||||
|
||||
### 7.5 Completion and Progress Tracking
|
||||
|
||||
- Lab completion is stored per-student per-lesson block.
|
||||
- Each completion record includes:
|
||||
- `studentId`
|
||||
- `lessonId`
|
||||
- `blockId` (the specific block within the lesson)
|
||||
- `labSlug`
|
||||
- `labVersion`
|
||||
- `configHash` (hash of the config used — ensures auditability)
|
||||
- `score` (0–100 or null if Lab doesn't produce a score)
|
||||
- `passed` (boolean or null)
|
||||
- `completedAt` (timestamp)
|
||||
- `attemptNumber`
|
||||
- `metadata` (arbitrary JSON from the Lab's `complete` event — e.g. per-round breakdown)
|
||||
- A Lab block with a required pass score blocks lesson completion if the student has not passed it.
|
||||
- A Lab block without a required pass score only requires the student to reach the `complete` event (any score).
|
||||
- Org admins can export Lab completion records per student as part of certification audit exports.
|
||||
|
||||
### 7.6 Retry Policy
|
||||
|
||||
- Teacher can configure:
|
||||
- **Unlimited retries** (default)
|
||||
- **Limited retries** (e.g. max 3 attempts)
|
||||
- **No retry** (one attempt only)
|
||||
- On retry, the iframe reloads with the same config but a fresh internal state.
|
||||
- Each attempt is stored as a separate completion record. The "official" result for certification purposes is the student's **best** score across attempts.
|
||||
|
||||
### 7.7 Lab Versioning in Lessons
|
||||
|
||||
- Each Lab block records the `labVersion` at the time it is configured.
|
||||
- When a new Lab version is published to the marketplace, existing lesson blocks are **not** automatically updated.
|
||||
- A **version badge** appears on the Lab block in the editor if the block is behind the latest version:
|
||||
- Yellow badge: minor update available (non-breaking config changes).
|
||||
- Red badge: major update available (breaking config changes — config may need to be reconfigured).
|
||||
- Teachers can explicitly upgrade to the new version from the block settings.
|
||||
- Students who completed the Lab under an older version do not need to redo it unless the teacher explicitly requires it.
|
||||
|
||||
---
|
||||
|
||||
## 8. Technical Architecture
|
||||
|
||||
### 8.1 System Components
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ classroomio-labs (GitHub repo) │
|
||||
│ │
|
||||
│ apps/ │
|
||||
│ phishing-detector/ │
|
||||
│ index.svelte ← entire Lab (logic + UI + style)│
|
||||
│ manifest.json ← metadata + config schema │
|
||||
│ preview.png │
|
||||
│ │
|
||||
│ sdk/ │
|
||||
│ index.ts ← postMessage helpers + types │
|
||||
│ harness/ ← local dev simulator │
|
||||
│ │
|
||||
│ scripts/ │
|
||||
│ build.ts ← compiles all labs to dist/ │
|
||||
│ validate.ts ← validates manifest + contract │
|
||||
└──────────────────────────────────┬─────────────────────────┘
|
||||
│ CI build + deploy
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ labs.classroomio.com (static CDN) │
|
||||
│ │
|
||||
│ /phishing-detector/v1.2.0/index.html │
|
||||
│ /phishing-detector/latest/index.html │
|
||||
│ /registry.json ← list of all labs + manifests │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│
|
||||
iframe src=
|
||||
│
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ ClassroomIO Dashboard (lesson editor / viewer) │
|
||||
│ │
|
||||
│ LabBlock.svelte ─── postMessage ──► Lab iframe │
|
||||
│ ▲ │
|
||||
│ │ stores config, completion records │
|
||||
│ ClassroomIO API / DB │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 8.2 postMessage Interface Specification
|
||||
|
||||
All messages are JSON objects with a `type` discriminant.
|
||||
|
||||
#### ClassroomIO → Lab iframe
|
||||
|
||||
**`init`** — sent immediately after the iframe loads. The Lab must wait for this before rendering.
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: 'init';
|
||||
mode: 'teacher' | 'student';
|
||||
config: Record<string, unknown>; // teacher-saved config (empty object if not yet configured)
|
||||
locale: string; // e.g. 'en', 'fr'
|
||||
context: {
|
||||
studentId?: string; // only in student mode, opaque token (not email/name)
|
||||
attemptNumber: number; // 1-indexed
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**`config_update`** — sent in teacher mode when the teacher changes config values in the picker modal form. The Lab should update its preview without full re-render.
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: 'config_update';
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
**`resize_ack`** — sent in response to a `resize_request` from the Lab (see below).
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: 'resize_ack';
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
```
|
||||
|
||||
#### Lab iframe → ClassroomIO
|
||||
|
||||
**`ready`** — emitted when the Lab has finished initializing and is ready for `init`.
|
||||
|
||||
```typescript
|
||||
{ type: 'ready' }
|
||||
```
|
||||
|
||||
**`resize_request`** — emitted when the Lab needs a different iframe dimension.
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: 'resize_request';
|
||||
width?: number; // preferred width in px, omit for full-width
|
||||
height: number; // preferred height in px
|
||||
}
|
||||
```
|
||||
|
||||
**`progress`** — emitted periodically to report student progress through the Lab.
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: 'progress';
|
||||
percent: number; // 0–100
|
||||
label?: string; // e.g. "Round 3 of 5"
|
||||
}
|
||||
```
|
||||
|
||||
**`complete`** — emitted when the Lab activity is finished.
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: 'complete';
|
||||
score: number | null; // 0–100, null if unscored
|
||||
passed: boolean | null; // null if no pass threshold in Lab
|
||||
durationSeconds: number; // total active time
|
||||
summary: {
|
||||
headline: string; // e.g. "You caught 4 out of 5 phishing emails"
|
||||
details?: Record<string, unknown>; // arbitrary breakdown for audit metadata
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**`error`** — emitted if the Lab enters an unrecoverable error state.
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: 'error';
|
||||
message: string; // human-readable, shown in ClassroomIO fallback UI
|
||||
}
|
||||
```
|
||||
|
||||
#### Security constraints on postMessage
|
||||
- ClassroomIO only accepts messages from the iframe's known origin (`labs.classroomio.com`).
|
||||
- The iframe is served with the following headers:
|
||||
- `Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; connect-src 'none'; frame-ancestors https://app.classroomio.com`
|
||||
- `connect-src: 'none'` — no outbound network requests.
|
||||
- The iframe sandbox attribute: `sandbox="allow-scripts allow-same-origin"` — no form submissions, no top-navigation, no popups.
|
||||
|
||||
### 8.3 Lab Build Pipeline
|
||||
|
||||
Each `apps/{slug}/index.svelte` is compiled by Vite + `@sveltejs/vite-plugin-svelte` to a self-contained `dist/{slug}/v{version}/index.html` (all JS/CSS inlined, no external dependencies).
|
||||
|
||||
The `scripts/build.ts` script:
|
||||
1. Reads each `manifest.json` and validates against the manifest schema.
|
||||
2. Compiles each Lab to `dist/{slug}/v{version}/index.html`.
|
||||
3. Also writes to `dist/{slug}/latest/index.html`.
|
||||
4. Generates `dist/registry.json` — the full marketplace catalog.
|
||||
|
||||
CI runs on every merged PR to `main`. Deployment is to a static CDN (Cloudflare Pages or equivalent).
|
||||
|
||||
### 8.4 Registry Format (`registry.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"updatedAt": "2026-05-03T00:00:00Z",
|
||||
"labs": [
|
||||
{
|
||||
"slug": "phishing-detector",
|
||||
"name": "Phishing Detector",
|
||||
"description": "Students identify phishing vs legitimate emails in a realistic inbox simulation.",
|
||||
"tags": ["cybersecurity", "compliance", "security-awareness"],
|
||||
"author": { "name": "ClassroomIO Team", "url": "https://classroomio.com" },
|
||||
"version": "1.2.0",
|
||||
"estimatedMinutes": 5,
|
||||
"previewImage": "https://labs.classroomio.com/phishing-detector/preview.png",
|
||||
"url": "https://labs.classroomio.com/phishing-detector/v1.2.0/index.html",
|
||||
"latestUrl": "https://labs.classroomio.com/phishing-detector/latest/index.html",
|
||||
"configSchema": {
|
||||
"difficulty": {
|
||||
"type": "select",
|
||||
"label": "Difficulty",
|
||||
"options": ["easy", "medium", "hard"],
|
||||
"default": "medium"
|
||||
},
|
||||
"rounds": {
|
||||
"type": "number",
|
||||
"label": "Number of rounds",
|
||||
"min": 3,
|
||||
"max": 10,
|
||||
"default": 5
|
||||
},
|
||||
"industry": {
|
||||
"type": "select",
|
||||
"label": "Industry context",
|
||||
"options": ["generic", "finance", "healthcare", "tech", "retail"],
|
||||
"default": "generic"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
ClassroomIO fetches `registry.json` at build time (SSG) or with a short TTL cache. The modal renders the list from this data.
|
||||
|
||||
---
|
||||
|
||||
## 9. Manifest Specification
|
||||
|
||||
Every Lab must include a `manifest.json` at its root.
|
||||
|
||||
```typescript
|
||||
type Manifest = {
|
||||
slug: string; // kebab-case, globally unique, immutable after first publish
|
||||
name: string; // display name, max 50 chars
|
||||
description: string; // max 160 chars
|
||||
tags: string[]; // from the approved tag list (see §11)
|
||||
author: {
|
||||
name: string;
|
||||
url?: string;
|
||||
email?: string;
|
||||
};
|
||||
version: string; // semver
|
||||
estimatedMinutes: number; // honest estimate for student
|
||||
minHeight: number; // minimum iframe height in px the Lab needs to function
|
||||
changelog: string; // human-readable summary of changes in this version
|
||||
configSchema: ConfigSchema;
|
||||
breakingChanges?: string; // if semver major bump, describe what config keys changed
|
||||
};
|
||||
|
||||
type ConfigSchema = Record<string, ConfigField>;
|
||||
|
||||
type ConfigField =
|
||||
| { type: 'text'; label: string; default?: string; maxLength?: number }
|
||||
| { type: 'number'; label: string; default?: number; min?: number; max?: number }
|
||||
| { type: 'boolean'; label: string; default?: boolean }
|
||||
| { type: 'select'; label: string; options: string[]; default?: string }
|
||||
| { type: 'multiselect'; label: string; options: string[]; default?: string[] };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Data Model
|
||||
|
||||
### `lab_block` (stored as part of lesson block)
|
||||
|
||||
This is a new block type stored within the existing lesson block structure.
|
||||
|
||||
```typescript
|
||||
type LabBlock = {
|
||||
type: 'lab';
|
||||
labSlug: string;
|
||||
labVersion: string; // pinned at time of insertion
|
||||
latestVersion: string; // checked at load time from registry
|
||||
config: Record<string, unknown>;
|
||||
configHash: string; // SHA-256 of JSON.stringify(config) — for audit
|
||||
passingScore: number | null; // 0–100, null = no threshold
|
||||
maxAttempts: number | null; // null = unlimited
|
||||
};
|
||||
```
|
||||
|
||||
### `lab_completion` (new table)
|
||||
|
||||
```typescript
|
||||
type LabCompletion = {
|
||||
id: string;
|
||||
studentId: string;
|
||||
courseId: string;
|
||||
lessonId: string;
|
||||
blockId: string; // references the specific lab block in the lesson
|
||||
labSlug: string;
|
||||
labVersion: string; // version the student ran
|
||||
configHash: string; // config snapshot at time of attempt
|
||||
attemptNumber: number;
|
||||
score: number | null;
|
||||
passed: boolean | null;
|
||||
durationSeconds: number;
|
||||
completedAt: Date;
|
||||
metadata: Record<string, unknown>; // Lab's summary.details
|
||||
};
|
||||
```
|
||||
|
||||
### Indexes needed
|
||||
- `(studentId, lessonId, blockId)` — for lesson progress checks
|
||||
- `(courseId, labSlug)` — for org-level audit reporting
|
||||
- `(studentId, courseId)` — for certification eligibility queries
|
||||
|
||||
---
|
||||
|
||||
## 11. Lab Catalog: Compliance-Focused Apps (v1)
|
||||
|
||||
The following Labs will be built and shipped as part of the initial release. All are targeted at the compliance training and certification niche.
|
||||
|
||||
### Lab 1: Phishing Detector
|
||||
**Slug:** `phishing-detector`
|
||||
**Estimated time:** 5 min
|
||||
**Tags:** `cybersecurity`, `security-awareness`, `compliance`
|
||||
|
||||
The student sees a realistic email inbox with a mix of legitimate and phishing emails. They must classify each one (Safe / Suspicious / Phishing). After each decision, feedback explains the signals they should have spotted. At the end, they see a score and a breakdown of which emails tripped them up.
|
||||
|
||||
**Config options:** difficulty (easy/medium/hard), number of emails (3–10), industry context (generic/finance/healthcare/tech/retail).
|
||||
|
||||
**Pass threshold:** Teacher-configurable (e.g. must correctly identify 80% of emails).
|
||||
|
||||
---
|
||||
|
||||
### Lab 2: Incident Response Playbook
|
||||
**Slug:** `incident-response`
|
||||
**Estimated time:** 8 min
|
||||
**Tags:** `cybersecurity`, `compliance`, `incident-management`
|
||||
|
||||
A security incident unfolds across 5–7 decision points. At each point the student reads a situation update and chooses from 3–4 options (e.g. "Escalate to CISO", "Contain the affected systems", "Wait for more information"). Wrong choices have cascading consequences shown in subsequent steps. The final score reflects how well the student contained the incident and followed correct procedure.
|
||||
|
||||
**Config options:** incident type (ransomware / data breach / insider threat / DDoS), organization size context, number of steps.
|
||||
|
||||
---
|
||||
|
||||
### Lab 3: Data Classification Sorter
|
||||
**Slug:** `data-classifier`
|
||||
**Estimated time:** 4 min
|
||||
**Tags:** `data-privacy`, `compliance`, `gdpr`, `hipaa`
|
||||
|
||||
The student sees a stream of documents, data snippets, and file descriptions. They drag each item into the correct classification bucket: Public / Internal / Confidential / Restricted. Items are drawn from realistic workplace examples (HR records, marketing materials, customer PII, code repositories). Immediate feedback per item.
|
||||
|
||||
**Config options:** classification framework (generic / GDPR / HIPAA / PCI-DSS), item count (5–15), industry context.
|
||||
|
||||
---
|
||||
|
||||
### Lab 4: Social Engineering Detector
|
||||
**Slug:** `social-engineering`
|
||||
**Estimated time:** 6 min
|
||||
**Tags:** `cybersecurity`, `security-awareness`, `compliance`
|
||||
|
||||
The student reads a realistic chat or phone call transcript that scrolls in real time (simulating live interaction). At key moments (every 2–3 exchanges), the transcript pauses and asks "What do you do?" with 3 options. The Lab tracks how many manipulation tactics the student identified correctly. At the end, annotated versions of the transcript highlight all the red flags.
|
||||
|
||||
**Config options:** scenario type (help desk impersonation / vendor impersonation / executive fraud), difficulty, number of decision points.
|
||||
|
||||
---
|
||||
|
||||
### Lab 5: Policy Adjudicator
|
||||
**Slug:** `policy-adjudicator`
|
||||
**Estimated time:** 5 min
|
||||
**Tags:** `compliance`, `hr-compliance`, `ethics`, `finance`
|
||||
|
||||
The student reviews workplace scenarios one at a time — an expense claim, a gift from a vendor, a request to share data with a partner, a colleague asking for system access. For each scenario, they choose: Approve / Reject / Escalate. A relevant policy extract is available as a collapsible reference panel. Feedback after each decision cites the specific policy clause.
|
||||
|
||||
**Config options:** policy domain (expense / gifts-and-conflicts / data-sharing / access-control), number of scenarios, policy strictness (lenient / standard / strict).
|
||||
|
||||
---
|
||||
|
||||
### Lab 6: Hazard Spotter
|
||||
**Slug:** `hazard-spotter`
|
||||
**Estimated time:** 5 min
|
||||
**Tags:** `health-safety`, `compliance`, `workplace-safety`
|
||||
|
||||
The student sees a detailed illustrated scene of a workplace (office, warehouse, lab, construction site). They must click on every safety hazard they can spot within a time limit. After submitting, all hazards are revealed and annotated. Score is based on hazards found versus missed and false positives (clicking something that is not a hazard).
|
||||
|
||||
**Config options:** workplace type, difficulty (hazard density), time limit, number of scenes.
|
||||
|
||||
---
|
||||
|
||||
### Lab 7: GDPR Rights Simulator
|
||||
**Slug:** `gdpr-rights`
|
||||
**Estimated time:** 6 min
|
||||
**Tags:** `data-privacy`, `gdpr`, `compliance`
|
||||
|
||||
The student takes the role of a Data Protection Officer handling a set of incoming data subject requests (right to access, right to erasure, right to portability, right to object). For each request, they must decide: what action to take, within what timeframe, and what to communicate back to the subject. Feedback references the correct GDPR articles.
|
||||
|
||||
**Config options:** request types to include, jurisdiction variants (EU GDPR / UK GDPR / CCPA), number of requests.
|
||||
|
||||
---
|
||||
|
||||
### Lab 8: Password & MFA Hygiene
|
||||
**Slug:** `password-hygiene`
|
||||
**Estimated time:** 4 min
|
||||
**Tags:** `cybersecurity`, `security-awareness`, `compliance`
|
||||
|
||||
An interactive two-part exercise: (1) the student rates a set of passwords as strong/weak and explains why, then sees annotated feedback; (2) the student walks through setting up MFA on a simulated account interface, choosing the most secure method from the options presented.
|
||||
|
||||
**Config options:** password count, MFA methods to include (authenticator app / SMS / hardware key).
|
||||
|
||||
---
|
||||
|
||||
## 12. Approved Tag List (v1)
|
||||
|
||||
Tags govern how Labs are organized in the marketplace picker. Only approved tags may be used in `manifest.json`.
|
||||
|
||||
```
|
||||
cybersecurity — Threats, attacks, defenses
|
||||
security-awareness — Recognition and behavior change
|
||||
compliance — Regulatory and policy adherence
|
||||
data-privacy — GDPR, HIPAA, CCPA, PII handling
|
||||
gdpr — GDPR-specific
|
||||
hipaa — HIPAA-specific
|
||||
pci-dss — PCI-DSS-specific
|
||||
hr-compliance — Workplace policy, harassment, ethics
|
||||
health-safety — Physical safety, hazard recognition
|
||||
workplace-safety — Overlaps health-safety, more procedural
|
||||
incident-management — Response procedures
|
||||
data-management — Classification, retention, handling
|
||||
finance — Expense policy, fraud awareness
|
||||
ethics — Conflicts of interest, gifts
|
||||
access-control — Identity, permissions, least privilege
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. Marketplace / Contribution Model
|
||||
|
||||
### Repository
|
||||
`github.com/classroomio/classroomio-labs` — public, open source (MIT).
|
||||
|
||||
### Directory Structure
|
||||
```
|
||||
classroomio-labs/
|
||||
apps/
|
||||
{slug}/
|
||||
index.svelte
|
||||
manifest.json
|
||||
preview.png (800×450 static image)
|
||||
preview.gif (optional, ≤ 2MB animated demo)
|
||||
README.md (required — describes the Lab, config options, pedagogy)
|
||||
sdk/
|
||||
src/
|
||||
index.ts (postMessage helpers, TypeScript types, constants)
|
||||
harness/
|
||||
index.html (local dev simulator — simulates ClassroomIO host)
|
||||
harness.ts
|
||||
packages/
|
||||
create-lab/ (CLI scaffolding tool — `npx create-classroomio-lab`)
|
||||
docs/
|
||||
contributing.md
|
||||
manifest-spec.md
|
||||
postmessage-spec.md
|
||||
design-guidelines.md
|
||||
scripts/
|
||||
build.ts
|
||||
validate.ts
|
||||
release.ts
|
||||
```
|
||||
|
||||
### Contribution Flow
|
||||
1. Developer runs `npx create-classroomio-lab` — scaffolds a new directory with a starter `index.svelte`, a `manifest.json` template, and links to the design guide.
|
||||
2. Developer builds the Lab locally using `pnpm dev --lab={slug}` which opens the harness in the browser, simulating the `init` message flow.
|
||||
3. Developer submits a PR with the new `apps/{slug}/` directory.
|
||||
4. CI runs:
|
||||
- Manifest validation (schema check, slug uniqueness, semver format).
|
||||
- Contract validation (does the compiled Lab respond to `init` and emit `ready` and `complete`?).
|
||||
- Lighthouse accessibility audit (must pass score ≥ 80).
|
||||
- Visual regression (snapshot of the compiled Lab at 1280px and 768px).
|
||||
5. ClassroomIO team reviews for pedagogical quality and content accuracy.
|
||||
6. Merged to `main` → CI builds and deploys to CDN → registry.json updated → appears in marketplace.
|
||||
|
||||
### SDK (`sdk/src/index.ts`)
|
||||
|
||||
The SDK is a tiny TypeScript module that Lab authors import to simplify the postMessage protocol:
|
||||
|
||||
```typescript
|
||||
import { LabSDK } from '@classroomio/lab-sdk';
|
||||
|
||||
const lab = new LabSDK();
|
||||
|
||||
lab.onInit((payload) => {
|
||||
// payload.mode, payload.config, payload.context
|
||||
initializeApp(payload);
|
||||
});
|
||||
|
||||
lab.onConfigUpdate((config) => {
|
||||
updatePreview(config);
|
||||
});
|
||||
|
||||
lab.complete({
|
||||
score: 85,
|
||||
passed: true,
|
||||
durationSeconds: 243,
|
||||
summary: {
|
||||
headline: 'You caught 4 out of 5 phishing emails',
|
||||
details: { caught: 4, missed: 1, falsePositives: 0 }
|
||||
}
|
||||
});
|
||||
|
||||
lab.progress({ percent: 60, label: 'Round 3 of 5' });
|
||||
```
|
||||
|
||||
### Design Guidelines for Contributors
|
||||
|
||||
Labs must follow these design rules to maintain visual consistency and quality:
|
||||
|
||||
- Use the Lab SDK's provided CSS variables for color (the SDK ships a minimal design token CSS file).
|
||||
- Minimum touch target size: 44×44px.
|
||||
- No external fonts. Use system font stack.
|
||||
- Must be usable at 320px viewport width and above.
|
||||
- Text must meet WCAG AA contrast ratio (4.5:1 for body text).
|
||||
- All interactive elements must be keyboard accessible.
|
||||
- No autoplay audio or video.
|
||||
- Animations must respect `prefers-reduced-motion`.
|
||||
|
||||
---
|
||||
|
||||
## 14. Security & Sandboxing
|
||||
|
||||
| Threat | Mitigation |
|
||||
|--------|-----------|
|
||||
| Lab makes network requests | `connect-src: 'none'` CSP + iframe `sandbox` attribute removes fetch/XHR ability |
|
||||
| Lab attempts top-level navigation | `sandbox="allow-scripts"` without `allow-top-navigation` |
|
||||
| Lab reads student PII | Only opaque `studentId` token passed; no email, name, or org data |
|
||||
| Lab exfiltrates config data | `frame-ancestors` CSP restricts who can embed the Lab |
|
||||
| Malicious Lab code in marketplace | PR review process + CI contract validation + manifest schema validation |
|
||||
| postMessage spoofing (non-lab origin) | ClassroomIO checks `event.origin === 'https://labs.classroomio.com'` before processing |
|
||||
| Compromised CDN | Subresource integrity (SRI) hash stored per Lab version in registry.json; iframe src checked against it |
|
||||
| Config injection | Config is JSON-serialized and passed as a structured message, never eval'd |
|
||||
|
||||
---
|
||||
|
||||
## 15. Accessibility Requirements
|
||||
|
||||
- All Labs must achieve Lighthouse accessibility score ≥ 80 (enforced in CI).
|
||||
- Labs must be operable by keyboard alone.
|
||||
- Labs must provide appropriate ARIA labels on all interactive elements.
|
||||
- Labs must not rely solely on color to convey meaning.
|
||||
- ClassroomIO wrapper around the iframe must have `title` attribute describing the Lab.
|
||||
- The "Start" overlay and result overlay rendered by ClassroomIO (not the Lab) fully comply with WCAG AA.
|
||||
|
||||
---
|
||||
|
||||
## 16. Versioning Strategy
|
||||
|
||||
Labs use **semver** (`MAJOR.MINOR.PATCH`).
|
||||
|
||||
| Change | Version bump |
|
||||
|--------|-------------|
|
||||
| Bug fix, visual tweak, wording change | `PATCH` |
|
||||
| New config option (backwards-compatible default) | `MINOR` |
|
||||
| Existing config option renamed or removed | `MAJOR` |
|
||||
| Scoring algorithm changes (affects pass/fail) | `MAJOR` |
|
||||
| Core interaction mechanic changes | `MAJOR` |
|
||||
|
||||
**Lesson blocks pin to the exact version used when the teacher configured the Lab.** The block continues to load that version even after newer versions are published. The editor shows an upgrade prompt for minor and major updates.
|
||||
|
||||
**For major version upgrades:** ClassroomIO warns the teacher that the config schema has changed and may need to be reconfigured. It does not auto-migrate config. Students who already passed under an older major version are not required to redo it unless the teacher explicitly resets completions.
|
||||
|
||||
---
|
||||
|
||||
## 17. Teacher UX Flow (End-to-End)
|
||||
|
||||
```
|
||||
Lesson Editor
|
||||
│
|
||||
├─ Click "+ Add Block"
|
||||
│
|
||||
├─ Block Picker → select "Lab"
|
||||
│
|
||||
├─ Lab Picker Modal opens
|
||||
│ ├─ Browse / search / filter by tag
|
||||
│ ├─ Select "Phishing Detector"
|
||||
│ │
|
||||
│ └─ Configuration Step
|
||||
│ ├─ Left panel: config form (from configSchema)
|
||||
│ │ difficulty: [Medium ▼]
|
||||
│ │ rounds: [5]
|
||||
│ │ industry: [Finance ▼]
|
||||
│ │ pass score: [80%]
|
||||
│ │ max attempts: [Unlimited ▼]
|
||||
│ │
|
||||
│ └─ Right panel: live iframe preview (teacher mode)
|
||||
│ updates as config changes
|
||||
│
|
||||
├─ Click "Add Lab"
|
||||
│
|
||||
└─ Lab Block appears in lesson
|
||||
├─ Shows: name, thumbnail, configured version
|
||||
├─ "Preview as Student" button → opens modal with student mode iframe
|
||||
└─ "Configure" button → reopens config modal
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 18. Student UX Flow (End-to-End)
|
||||
|
||||
```
|
||||
Lesson Page
|
||||
│
|
||||
├─ Student scrolls to Lab block
|
||||
│
|
||||
├─ Lab block shows:
|
||||
│ ├─ Lab name + description
|
||||
│ ├─ Estimated time: ~5 min
|
||||
│ ├─ Pass required: 80%
|
||||
│ └─ [Start Lab] button
|
||||
│
|
||||
├─ Student clicks "Start Lab"
|
||||
│ └─ Iframe loads in student mode with teacher config
|
||||
│
|
||||
├─ Student interacts with Lab
|
||||
│ ├─ Progress bar updates (from `progress` events)
|
||||
│ └─ Lab can request fullscreen expand
|
||||
│
|
||||
├─ Lab emits `complete`
|
||||
│
|
||||
├─ Result overlay appears (rendered by ClassroomIO):
|
||||
│ ├─ Score: 84/100
|
||||
│ ├─ ✅ Passed (required: 80%)
|
||||
│ ├─ "You caught 4 out of 5 phishing emails"
|
||||
│ └─ [Continue Lesson] or [Try Again]
|
||||
│
|
||||
└─ Lab block marked complete ✅ in lesson progress
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 19. AI Assistant Integration
|
||||
|
||||
The existing ClassroomIO AI assistant (used by teachers) can recommend Labs contextually.
|
||||
|
||||
- When a teacher is editing a lesson on a topic (e.g. "Identifying Phishing Attacks"), the AI assistant can suggest: *"Would you like to add a Phishing Detector lab to give students hands-on practice with this topic?"*
|
||||
- The AI assistant can call the existing lesson content tools to insert a Lab block with a pre-set config.
|
||||
- The AI does not generate Lab code. It only selects from the available registry and configures from known config schemas.
|
||||
|
||||
Tool to expose to the AI assistant:
|
||||
|
||||
```typescript
|
||||
insert_lab_block({
|
||||
lessonId: string,
|
||||
afterBlockId: string,
|
||||
labSlug: string,
|
||||
config: Record<string, unknown>,
|
||||
passingScore?: number,
|
||||
maxAttempts?: number
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 20. Analytics & Reporting
|
||||
|
||||
### Teacher-level analytics (per Lab block)
|
||||
- Completion rate (% of enrolled students who completed the Lab)
|
||||
- Pass rate (% of completions that passed the threshold)
|
||||
- Average score
|
||||
- Average number of attempts
|
||||
- Average time on Lab
|
||||
- Score distribution histogram
|
||||
|
||||
### Org-level analytics (compliance dashboard)
|
||||
- Labs completed per student per course
|
||||
- Labs failed / retried per student
|
||||
- Aggregate pass rates across the organization by tag (e.g. "cybersecurity Labs: 73% pass rate")
|
||||
- Downloadable CSV of all `lab_completion` records for audit
|
||||
|
||||
### Lab-level analytics (for marketplace contributors — anonymized)
|
||||
- Total completions across all ClassroomIO instances
|
||||
- Average score by difficulty
|
||||
- Average time to complete
|
||||
- Most common failure points (from `metadata.details` aggregated anonymously)
|
||||
|
||||
---
|
||||
|
||||
## 21. Success Metrics
|
||||
|
||||
| Metric | Target (6 months post-launch) |
|
||||
|--------|-------------------------------|
|
||||
| Labs added to lessons (teacher adoption) | 30% of active courses have ≥ 1 Lab block |
|
||||
| Student engagement | ≥ 85% of students who reach a Lab block start it |
|
||||
| Student completion | ≥ 75% of started Labs reach the `complete` event |
|
||||
| Marketplace growth | ≥ 12 Labs published (8 internal + 4 community) |
|
||||
| Completion record accuracy | 0 data loss incidents on completion records |
|
||||
| Developer experience | Median time from `npx create-classroomio-lab` to first `complete` event in harness ≤ 30 min |
|
||||
|
||||
---
|
||||
|
||||
## 22. Phased Roadmap
|
||||
|
||||
### Phase 1: Foundation (Weeks 1–6)
|
||||
- [ ] `classroomio-labs` repository scaffold (SDK, harness, build pipeline, manifest spec)
|
||||
- [ ] `npx create-classroomio-lab` CLI scaffold
|
||||
- [ ] postMessage interface implemented in ClassroomIO lesson viewer
|
||||
- [ ] Lab block type in lesson editor (add, configure, reorder, delete)
|
||||
- [ ] Lab Picker Modal (from local registry.json fixture)
|
||||
- [ ] Teacher Mode and Student Mode iframe wrappers
|
||||
- [ ] Completion tracking (database table + API endpoint)
|
||||
- [ ] CDN deployment pipeline
|
||||
- [ ] **Lab 1: Phishing Detector** (first Lab, used to validate entire stack)
|
||||
|
||||
### Phase 2: Compliance Pack (Weeks 7–12)
|
||||
- [ ] Marketplace picker connected to live registry.json from CDN
|
||||
- [ ] Version badge on Lab blocks (upgrade available notification)
|
||||
- [ ] Retry policy configuration for teachers
|
||||
- [ ] Pass threshold enforcement in lesson progress
|
||||
- [ ] **Labs 2–5:** Incident Response, Data Classifier, Social Engineering Detector, Policy Adjudicator
|
||||
- [ ] Org-level Lab completion reporting in admin dashboard
|
||||
|
||||
### Phase 3: Ecosystem (Weeks 13–20)
|
||||
- [ ] Public marketplace website (`labs.classroomio.com`) with filtering, search, previews
|
||||
- [ ] Contribution documentation and design guidelines published
|
||||
- [ ] CI contract validation and accessibility checks live
|
||||
- [ ] AI assistant Lab recommendation integration
|
||||
- [ ] **Labs 6–8:** Hazard Spotter, GDPR Rights Simulator, Password Hygiene
|
||||
- [ ] Analytics dashboard for teachers (completion rate, pass rate, score distribution)
|
||||
- [ ] Community contributions open
|
||||
|
||||
### Phase 4: Scale (Months 6+)
|
||||
- [ ] Monetization model for third-party Lab developers (revenue share)
|
||||
- [ ] Org-level custom Lab hosting (private Labs not in public marketplace)
|
||||
- [ ] Lab A/B testing (two configs, random assignment, compare outcomes)
|
||||
- [ ] Offline support (Service Worker caching of Lab iframe for LMS environments with limited connectivity)
|
||||
|
||||
---
|
||||
|
||||
## 23. Open Questions
|
||||
|
||||
| # | Question | Owner | Priority |
|
||||
|---|----------|-------|----------|
|
||||
| 1 | Should completion records count toward LMS-grade exports (SCORM/xAPI)? If so, Labs may need to emit xAPI statements. | Product + Engineering | High |
|
||||
| 2 | What is the SLA for CDN availability? Labs blocking lesson progress means CDN downtime = blocked students. Need fallback (graceful degradation: show "Lab temporarily unavailable, lesson progress unblocked"). | Engineering | High |
|
||||
| 3 | Do Labs need to support RTL languages? Several target compliance markets (Middle East, Israel) are RTL. | Design | Medium |
|
||||
| 4 | Can a teacher build a private Lab (not published to the marketplace) and use it in their org? This needs a private hosting model. | Product | Medium |
|
||||
| 5 | Should the AI assistant be able to configure a Lab it recommends, or only suggest and require the teacher to configure manually? | Product | Medium |
|
||||
| 6 | For HIPAA and other regulated contexts, can the `metadata.details` stored in `lab_completion` contain anything that could be considered PHI? Need a data handling policy. | Legal + Engineering | High |
|
||||
| 7 | What is the review SLA for community Lab submissions? A slow review process kills ecosystem momentum. | Community | Medium |
|
||||
| 8 | Should Labs support multiple languages, or is the teacher responsible for picking a language-appropriate Lab? | Product | Low |
|
||||
|
||||
---
|
||||
|
||||
## Appendix A: Glossary
|
||||
|
||||
| Term | Definition |
|
||||
|------|-----------|
|
||||
| Lab | A single interactive activity app, built as one Svelte file, embedded in a lesson via iframe |
|
||||
| Lab Block | A lesson content block of type `lab`, storing the slug, version, and config for one Lab |
|
||||
| Manifest | A `manifest.json` file in each Lab's directory describing its metadata and config schema |
|
||||
| Config | A JSON object of teacher-set values passed to the Lab at `init` time |
|
||||
| Config Schema | A JSON schema in `manifest.json` that describes what config fields the Lab accepts |
|
||||
| Registry | `registry.json` — the CDN-hosted catalog of all published Labs |
|
||||
| Teacher Mode | The iframe mode where the Lab renders its configuration UI and preview |
|
||||
| Student Mode | The iframe mode where the Lab renders its interactive experience |
|
||||
| Complete Event | The `postMessage` event emitted by a Lab when the student finishes the activity |
|
||||
| Harness | A local browser simulator that mimics the ClassroomIO host for Lab development |
|
||||
|
||||
---
|
||||
|
||||
## Appendix B: Rejected Alternatives
|
||||
|
||||
**Why not iframes served from the same origin?**
|
||||
Same-origin iframes can access parent window globals and DOM, defeating the sandbox. Cross-origin is required for security isolation.
|
||||
|
||||
**Why not Web Components?**
|
||||
Web Components run in the same JS context as the lesson page — no sandboxing, no isolation. Any Lab bug could crash the whole lesson page.
|
||||
|
||||
**Why not a JSON-based DSL for building Labs?**
|
||||
A DSL limits what Labs can express. The single-Svelte-file model gives contributors full control over interaction design, which is exactly what makes interesting Labs possible. The postMessage interface provides the structural constraint without constraining the experience.
|
||||
|
||||
**Why not embed Labs as full pages with their own routing?**
|
||||
Labs are inherently sub-page components — they belong inside a lesson, alongside other content. Routing to a separate page breaks the lesson flow and makes it impossible to have multiple Labs per lesson.
|
||||
@@ -20,17 +20,32 @@ export const ZUpdateProfile = z.object({
|
||||
export type TUpdateProfile = z.infer<typeof ZUpdateProfile>;
|
||||
```
|
||||
|
||||
### Step 2: Service
|
||||
### Step 2: Database query
|
||||
Put all direct database access in pure query functions under `packages/db/src/queries/{domain}/`. Routes and services never call Drizzle/`db` (or equivalent) directly—they import and compose these helpers.
|
||||
|
||||
```typescript
|
||||
// packages/db/src/queries/auth/profile.ts
|
||||
export async function updateProfile(userId: string, data: Partial<TProfile>) {
|
||||
// …db update returning the row or null if missing
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Service
|
||||
Services orchestrate one or more query functions (often inside a transaction), apply business rules, and raise `AppError` when something is invalid or missing. **Do not put SQL or other direct DB access in services**—only call query-layer functions and handle in-memory shaping or validation there.
|
||||
|
||||
```typescript
|
||||
// apps/api/src/services/account.ts
|
||||
import { updateProfile } from '@cio/db/queries/auth';
|
||||
|
||||
export async function updateUser(userId: string, data: Partial<TProfile>) {
|
||||
const updated = await updateProfile(userId, data);
|
||||
if (!updated) throw new AppError('Profile not found', ErrorCodes.PROFILE_NOT_FOUND, 404);
|
||||
|
||||
return updated;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Route
|
||||
### Step 4: Route
|
||||
Routes must return a single type (or a response shape whose `data` is a single type). If multiple shapes are needed, use separate endpoints or a keyed structure (e.g. `data` indexed by a key) so clients get one type per endpoint or key. Do not return a union of shapes from one route based on query parameters.
|
||||
|
||||
```typescript
|
||||
@@ -52,7 +67,7 @@ export const accountRouter = new Hono().put(
|
||||
);
|
||||
```
|
||||
|
||||
### Step 4: Register
|
||||
### Step 5: Register
|
||||
```typescript
|
||||
// apps/api/src/routes/account/index.ts
|
||||
export * from './account';
|
||||
@@ -62,12 +77,12 @@ import { accountRouter } from '@api/routes/account';
|
||||
export const app = new Hono().route('/account', accountRouter);
|
||||
```
|
||||
|
||||
### Step 5: Build
|
||||
### Step 6: Build
|
||||
```bash
|
||||
pnpm --filter @cio/api build
|
||||
```
|
||||
|
||||
### Step 6: Frontend Types & API Class
|
||||
### Step 7: Frontend Types & API Class
|
||||
```typescript
|
||||
// apps/dashboard/src/lib/features/account/utils/types.ts
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
|
||||
+15
-11
@@ -9,20 +9,13 @@ Use this file to keep a single prioritized view of all PRDs.
|
||||
| 1 | - | `prd/notification-system` | todo |
|
||||
| 2 | 14 | `prd/course-templates` | todo |
|
||||
| 3 | 5 | `prd/course-import` | todo |
|
||||
| 4 | 8 | `prd/exercise-question-types` | in-progress (A3, A6, A7, C3, C7, C13, C14 done) |
|
||||
| 4 | 8 | `prd/exercise-question-types [DONE]` | in-progress (dedicated shipped work: exercise sections + video recording; see DONE) |
|
||||
| 5 | 3 | `prd/course-cohorts` | todo |
|
||||
| 6 | - | `prd/programs` | todo |
|
||||
| 7 | 13 | `prd/self-host-single-org.md` | done |
|
||||
| 8 | 7 | `prd/email-template-customization` | todo |
|
||||
| 9 | - | `prd/selective-exercise-assignment` | todo |
|
||||
| - | - | `prd/mcp-course-authoring` | todo |
|
||||
| - | 1 | `prd/ai-course-assistant` | todo |
|
||||
| - | 2 | `prd/compliance-training-platform` | todo |
|
||||
| 6 | 7 | `prd/email-template-customization` | todo |
|
||||
| 7 | - | `prd/selective-exercise-assignment` | todo |
|
||||
| - | 4 | `prd/course-content-search` | 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 |
|
||||
| - | 12 | `prd/public-courses` | todo |
|
||||
| - | - | `prd/customer-course-documentation` | todo |
|
||||
| - | 15 | `prd/webhooks` | todo |
|
||||
@@ -31,9 +24,20 @@ Use this file to keep a single prioritized view of all PRDs.
|
||||
|
||||
| PRD | Status |
|
||||
| --- | --- |
|
||||
| `prd/enterprise-sso` | done |
|
||||
| `prd/ai-course-assistant [DONE]` | done |
|
||||
| `prd/compliance-training-platform [DONE]` | done |
|
||||
| `prd/course-widget-embed [DONE]` | done |
|
||||
| `prd/custom-domain-cloudflare [DONE]` | done |
|
||||
| `prd/enterprise-sso [DONE]` | done |
|
||||
| `prd/exercise-sections [DONE]` | done |
|
||||
| `prd/lesson-edit [DONE]` | done |
|
||||
| `prd/lessons-list [DONE]` | done |
|
||||
| `prd/media-manager [DONE]` | done |
|
||||
| `prd/mcp-course-authoring [DONE]` | done |
|
||||
| `prd/org-landing-page-theme-picker [DONE]` | done |
|
||||
| `prd/programs [DONE]` | done |
|
||||
| `prd/public-api [DONE]` | done |
|
||||
| `prd/secure-student-invites [DONE]` | done |
|
||||
| `prd/self-host-single-org.md` | done |
|
||||
| `prd/tags-feature [DONE]` | done |
|
||||
| `prd/video-recording-question-type [DONE]` | done |
|
||||
|
||||
@@ -194,7 +194,7 @@ 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).
|
||||
- More detail on architecture, tools, and routes lives in [`prd/ai-course-assistant [DONE]/README.md`](prd/ai-course-assistant%20[DONE]/README.md).
|
||||
|
||||
### Docker Compose (Full Stack)
|
||||
|
||||
@@ -206,3 +206,12 @@ cp .env.example .env # copy env template, edit for your domain
|
||||
The script reads root `.env` via `docker compose --env-file .env` and auto-generates secure values for `AUTH_BEARER_TOKEN` and `PRIVATE_SERVER_KEY` when missing.
|
||||
|
||||
See [`.env.example`](.env.example) for the full list of environment variables with required/optional grouping, and [`docker/docs/SELF_HOST.md`](docker/docs/SELF_HOST.md) for the complete Docker self-hosting guide.
|
||||
|
||||
## Publishing
|
||||
|
||||
When cutting releases for hosted assets or the npm MCP package, run:
|
||||
|
||||
- **Storybook** — `pnpm --filter @cio/storybook storybook:publish`
|
||||
- **Course widget embed** — `pnpm --filter @cio/embeds embeds:publish`
|
||||
- **Question type picker** — `pnpm --filter @cio/embeds embeds:publish` (same script builds and uploads both embeds)
|
||||
- **MCP (`@classroomio/mcp`)** — bump the version in `packages/mcp/package.json`, then `pnpm mcp:build` and `pnpm --filter @classroomio/mcp publish`
|
||||
|
||||
@@ -49,6 +49,7 @@ LICENSE_KEY=""
|
||||
OPENAI_API_KEY=""
|
||||
GOOGLE_API_KEY=""
|
||||
ANTHROPIC_API_KEY=""
|
||||
MOONSHOT_API_KEY=""
|
||||
|
||||
# Tinybird event tracking for AI observability (optional — silently skipped when not set)
|
||||
# TINYBIRD_TOKEN=""
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { computePurchasedTokenOverflow } from '@api/services/agent/usage';
|
||||
|
||||
describe('computePurchasedTokenOverflow', () => {
|
||||
it('returns 0 when usage stays within allowance', () => {
|
||||
expect(
|
||||
computePurchasedTokenOverflow({
|
||||
allowance: 1000,
|
||||
monthlyUsageBefore: 500,
|
||||
requestTokens: 400
|
||||
})
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('returns partial overflow when request crosses allowance boundary', () => {
|
||||
expect(
|
||||
computePurchasedTokenOverflow({
|
||||
allowance: 1000,
|
||||
monthlyUsageBefore: 900,
|
||||
requestTokens: 200
|
||||
})
|
||||
).toBe(100);
|
||||
});
|
||||
|
||||
it('charges entire request against credits when allowance already exhausted', () => {
|
||||
expect(
|
||||
computePurchasedTokenOverflow({
|
||||
allowance: 1000,
|
||||
monthlyUsageBefore: 1200,
|
||||
requestTokens: 50
|
||||
})
|
||||
).toBe(50);
|
||||
});
|
||||
|
||||
it('handles zero allowance', () => {
|
||||
expect(
|
||||
computePurchasedTokenOverflow({
|
||||
allowance: 0,
|
||||
monthlyUsageBefore: 0,
|
||||
requestTokens: 10
|
||||
})
|
||||
).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('leaderboard percentage split', () => {
|
||||
it('allocates shares that sum to 1', () => {
|
||||
const rows = [{ tokens: 30 }, { tokens: 70 }];
|
||||
const total = rows.reduce((sum, row) => sum + row.tokens, 0);
|
||||
const percentages = rows.map((row) => (total > 0 ? row.tokens / total : 0));
|
||||
|
||||
expect(percentages).toEqual([0.3, 0.7]);
|
||||
expect(percentages.reduce((a, b) => a + b, 0)).toBeCloseTo(1);
|
||||
});
|
||||
});
|
||||
@@ -290,6 +290,38 @@ describe('buildUpdatedQuestions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('in-exercise section assignment', () => {
|
||||
const sectionA = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
|
||||
const sectionB = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
|
||||
|
||||
it('sets exerciseSectionId when provided', () => {
|
||||
const current = makeQuestion({ exerciseSectionId: sectionA });
|
||||
const patch: QuestionPatch = { id: 1, exerciseSectionId: sectionB };
|
||||
|
||||
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
|
||||
|
||||
expect(result.exerciseSectionId).toBe(sectionB);
|
||||
});
|
||||
|
||||
it('clears exerciseSectionId when patch passes null', () => {
|
||||
const current = makeQuestion({ exerciseSectionId: sectionA });
|
||||
const patch: QuestionPatch = { id: 1, exerciseSectionId: null };
|
||||
|
||||
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
|
||||
|
||||
expect(result.exerciseSectionId).toBeNull();
|
||||
});
|
||||
|
||||
it('omits exerciseSectionId when patch does not mention it', () => {
|
||||
const current = makeQuestion({ exerciseSectionId: sectionA });
|
||||
const patch: QuestionPatch = { id: 1, points: 2 };
|
||||
|
||||
const [result] = buildUpdatedQuestions([current], [patch], EXERCISE_ID);
|
||||
|
||||
expect(result).not.toHaveProperty('exerciseSectionId');
|
||||
});
|
||||
});
|
||||
|
||||
describe('safety guarantees', () => {
|
||||
it('throws when the patched id is not in the exercise', () => {
|
||||
const current = makeQuestion({ id: 1 });
|
||||
|
||||
+4
-14
@@ -17,6 +17,7 @@ import { mailRouter } from '@api/routes/mail';
|
||||
import { mediaRouter } from '@api/routes/media';
|
||||
import { onboardingRouter } from '@api/routes/onboarding';
|
||||
import { organizationRouter } from '@api/routes/organization';
|
||||
import { publicCourseRouter } from '@api/routes/org-site';
|
||||
import { licenseRouter } from '@api/routes/license';
|
||||
import { organizationSsoRouter } from '@api/routes/organization/sso';
|
||||
import { organizationTokenAuthRouter } from '@api/routes/organization/token-auth';
|
||||
@@ -29,14 +30,7 @@ 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 { agentRouter } from '@api/routes/agent';
|
||||
import { internalComplianceRouter } from '@api/routes/internal/compliance';
|
||||
|
||||
// Create Hono app with chaining for RPC support
|
||||
@@ -108,17 +102,13 @@ export const app = new Hono()
|
||||
.route('/dash', dashAnalyticsRouter)
|
||||
.route('/community', communityRouter)
|
||||
.route('/invite', inviteRouter)
|
||||
.route('/org-site/course', publicCourseRouter)
|
||||
.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)
|
||||
.route('/agent', agentRouter)
|
||||
|
||||
// Error handling
|
||||
.onError((err, c) => {
|
||||
|
||||
@@ -8,7 +8,6 @@ export const TRUSTED_ORIGINS = env.TRUSTED_ORIGINS
|
||||
? env.TRUSTED_ORIGINS.split(',')
|
||||
: ['http://localhost:5173', 'http://localhost:5180', 'http://*.classroomio.*'];
|
||||
|
||||
console.log('TRUSTED_ORIGINSs', TRUSTED_ORIGINS);
|
||||
const {
|
||||
CLOUDFLARE_BUCKET_DOMAIN,
|
||||
CLOUDFLARE_ACCESS_KEY,
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { authMiddleware } from '@api/middlewares/auth';
|
||||
import { orgMemberMiddleware } from '@api/middlewares/org-member';
|
||||
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
|
||||
import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key';
|
||||
import { handleError, AppError } from '@api/utils/errors';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { streamText, stepCountIs, convertToModelMessages } from 'ai';
|
||||
import {
|
||||
ZAgentChatBody,
|
||||
ZAgentCreditPurchase,
|
||||
ZAgentCreditsBody,
|
||||
ZAgentGenerateCourseTitleBody,
|
||||
ZAgentStatusQuery
|
||||
} from '@cio/utils/validation/agent';
|
||||
import {
|
||||
addCredits,
|
||||
enforceTokenBalance,
|
||||
getDetailedUsage,
|
||||
getPurchasedSummary,
|
||||
getTeamLeaderboard,
|
||||
getTokenBalance,
|
||||
isOrgOnPaidPlan,
|
||||
recordTokenUsage
|
||||
} from '@api/services/agent/usage';
|
||||
import { parseAndStoreDocument } from '@api/services/agent/document';
|
||||
import { recordCreditPurchase } from '@api/services/agent/credit-purchase';
|
||||
import { generateCourseMeta } from '@api/services/agent/title-generation';
|
||||
import { isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group';
|
||||
import { getChatConversation } from '@cio/db/queries/agent';
|
||||
import { AgentRole, AIProvider, MAX_STEPS_PER_ROUND, type AgentContext, type AgentStatus } from '@cio/ai-assistant';
|
||||
import { createModel, getProviderConfigForProvider, pickAnyConfiguredProvider } from '@cio/ai-assistant/providers';
|
||||
import { AGENT_MODELS, DEFAULT_PICKER_MODEL_ID } from '@cio/utils/agent-models';
|
||||
import { buildSystemPrompt } from '@cio/ai-assistant/prompt';
|
||||
import { trackAgentEvent, AgentEvent } from '@api/utils/tinybird';
|
||||
import { redis } from '@api/utils/redis/redis';
|
||||
import { db } from '@cio/db';
|
||||
import * as schema from '@cio/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { listCourseSections } from '@api/services/course/section';
|
||||
import { getLesson } from '@api/services/lesson/lesson';
|
||||
import { getExercise } from '@api/services/exercise/exercise';
|
||||
import { trimMessageHistory } from '@api/services/agent/context-window';
|
||||
import {
|
||||
collectDocumentIds,
|
||||
getLatestImplementationPlan,
|
||||
loadDocumentsText,
|
||||
verifyExerciseBelongsToCourse,
|
||||
verifyLessonBelongsToCourse
|
||||
} from '@api/services/agent/chat-context';
|
||||
import { buildAgentTools } from '@api/services/agent/chat-tools';
|
||||
import { agentHistoryRouter } from './history';
|
||||
|
||||
const agentCoreRouter = 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');
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
const isTeamMember = await isCourseTeamMemberOrOrgAdmin(courseId, user.id);
|
||||
const role = isTeamMember ? AgentRole.TEACHER : AgentRole.STUDENT;
|
||||
|
||||
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');
|
||||
}
|
||||
})
|
||||
.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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const conversation = await getChatConversation(conversationId, user.id);
|
||||
if (!conversation || conversation.courseId !== courseId) {
|
||||
throw new AppError('Conversation not found', 'CONVERSATION_NOT_FOUND', 404);
|
||||
}
|
||||
|
||||
const isPaid = await isOrgOnPaidPlan(orgId);
|
||||
if (!isPaid) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'document_upload_requires_upgrade',
|
||||
upgradeRequired: true
|
||||
},
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
})
|
||||
.get('/usage/purchased', authMiddleware, orgMemberMiddleware, async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const data = await getPurchasedSummary(orgId);
|
||||
|
||||
return c.json({ success: true, data });
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to fetch purchased summary');
|
||||
}
|
||||
})
|
||||
.get('/usage/leaderboard', authMiddleware, orgMemberMiddleware, async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const data = await getTeamLeaderboard(orgId);
|
||||
|
||||
return c.json({ success: true, data });
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to fetch leaderboard');
|
||||
}
|
||||
})
|
||||
.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');
|
||||
}
|
||||
})
|
||||
.post('/credits', authMiddleware, orgAdminMiddleware, zValidator('json', ZAgentCreditsBody), async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const { amount } = c.req.valid('json');
|
||||
|
||||
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');
|
||||
}
|
||||
})
|
||||
.post('/credits/purchase', authOrApiKeyMiddleware, zValidator('json', ZAgentCreditPurchase), async (c) => {
|
||||
try {
|
||||
const body = c.req.valid('json');
|
||||
const purchase = await recordCreditPurchase(body);
|
||||
|
||||
return c.json({ success: true, data: purchase });
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to record credit purchase');
|
||||
}
|
||||
})
|
||||
.post(
|
||||
'/generate-course-title',
|
||||
authMiddleware,
|
||||
orgMemberMiddleware,
|
||||
zValidator('json', ZAgentGenerateCourseTitleBody),
|
||||
async (c) => {
|
||||
try {
|
||||
const { prompt } = c.req.valid('json');
|
||||
|
||||
const providerConfig = pickAnyConfiguredProvider();
|
||||
|
||||
if (!providerConfig) {
|
||||
throw new AppError('AI assistant is not configured', 'AI_NOT_CONFIGURED', 503);
|
||||
}
|
||||
|
||||
const meta = await generateCourseMeta(prompt, providerConfig);
|
||||
|
||||
return c.json({ success: true as const, data: meta });
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to generate course title');
|
||||
}
|
||||
}
|
||||
)
|
||||
.post('/chat', authMiddleware, orgMemberMiddleware, zValidator('json', ZAgentChatBody), async (c) => {
|
||||
const user = c.get('user')!;
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
|
||||
try {
|
||||
const { courseId, messages, context, model: requestedModel } = c.req.valid('json');
|
||||
|
||||
const modelId = requestedModel ?? DEFAULT_PICKER_MODEL_ID;
|
||||
const modelDescriptor = AGENT_MODELS[modelId];
|
||||
const baseProviderConfig = getProviderConfigForProvider(modelDescriptor.provider as AIProvider);
|
||||
|
||||
if (!baseProviderConfig) {
|
||||
throw new AppError(`AI assistant is not configured for model "${modelId}"`, 'AI_NOT_CONFIGURED', 503);
|
||||
}
|
||||
|
||||
const providerConfig = { ...baseProviderConfig, model: modelDescriptor.backendModelId };
|
||||
|
||||
const isTeamMember = await isCourseTeamMemberOrOrgAdmin(courseId, user.id);
|
||||
const role = isTeamMember ? AgentRole.TEACHER : AgentRole.STUDENT;
|
||||
|
||||
if (role === AgentRole.STUDENT) {
|
||||
throw new AppError('AI assistant is not available for students yet', 'STUDENT_NOT_SUPPORTED', 403);
|
||||
}
|
||||
|
||||
const [courseRow] = await db
|
||||
.select({
|
||||
title: schema.course.title,
|
||||
description: schema.course.description,
|
||||
organizationId: schema.group.organizationId
|
||||
})
|
||||
.from(schema.course)
|
||||
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
|
||||
.where(eq(schema.course.id, courseId))
|
||||
.limit(1);
|
||||
|
||||
if (!courseRow) {
|
||||
throw new AppError('Course not found', 'COURSE_NOT_FOUND', 404);
|
||||
}
|
||||
|
||||
if (courseRow.organizationId !== orgId) {
|
||||
throw new AppError('Course does not belong to this organization', 'COURSE_ORG_MISMATCH', 403);
|
||||
}
|
||||
|
||||
if (!modelDescriptor.isFree) {
|
||||
const isPaid = await isOrgOnPaidPlan(orgId);
|
||||
|
||||
if (!isPaid) {
|
||||
throw new AppError('Upgrade required to use this AI model', 'UPGRADE_REQUIRED', 403);
|
||||
}
|
||||
}
|
||||
|
||||
await enforceTokenBalance(orgId);
|
||||
|
||||
const documentIds = collectDocumentIds(messages, context?.documentId);
|
||||
const documentText = documentIds.length > 0 ? await loadDocumentsText(documentIds, user.id) : undefined;
|
||||
|
||||
const existingSections = await listCourseSections(courseId);
|
||||
|
||||
let lessonTitle: string | undefined;
|
||||
let lessonContent: string | undefined;
|
||||
if (context?.lessonId) {
|
||||
try {
|
||||
await verifyLessonBelongsToCourse(context.lessonId, courseId);
|
||||
const lesson = await getLesson(context.lessonId);
|
||||
lessonTitle = lesson.title;
|
||||
const lessonWithLangs = lesson as {
|
||||
lessonLanguages?: Array<{ locale: string; content: string | null }>;
|
||||
};
|
||||
const langContent = lessonWithLangs.lessonLanguages?.find((ll) => ll.locale === 'en');
|
||||
lessonContent = langContent?.content || undefined;
|
||||
} catch {
|
||||
// Lesson not found or doesn't belong — continue without lesson context
|
||||
}
|
||||
}
|
||||
|
||||
let exerciseTitle: string | undefined;
|
||||
if (context?.exerciseId) {
|
||||
try {
|
||||
await verifyExerciseBelongsToCourse(context.exerciseId, courseId);
|
||||
const exercise = await getExercise(context.exerciseId);
|
||||
exerciseTitle = exercise.title;
|
||||
} catch {
|
||||
// Exercise not found or doesn't belong — continue without exercise context
|
||||
}
|
||||
}
|
||||
|
||||
const agentContext: AgentContext = {
|
||||
orgId,
|
||||
courseId,
|
||||
courseTitle: courseRow.title,
|
||||
courseDescription: courseRow.description || undefined,
|
||||
userId: user.id,
|
||||
role,
|
||||
locale: 'en',
|
||||
lessonId: context?.lessonId,
|
||||
lessonTitle,
|
||||
lessonContent,
|
||||
exerciseId: context?.exerciseId,
|
||||
exerciseTitle,
|
||||
documentId: context?.documentId,
|
||||
documentText,
|
||||
existingSectionCount: existingSections.length
|
||||
};
|
||||
|
||||
trackAgentEvent(AgentEvent.CHAT_STARTED, {
|
||||
orgId,
|
||||
userId: user.id,
|
||||
courseId,
|
||||
role,
|
||||
hasDocument: !!documentText,
|
||||
messageCount: messages.length
|
||||
});
|
||||
|
||||
const startTime = Date.now();
|
||||
const model = createModel(providerConfig);
|
||||
const approvedPlan = getLatestImplementationPlan(messages);
|
||||
const systemPrompt = [
|
||||
buildSystemPrompt(agentContext),
|
||||
approvedPlan
|
||||
? [
|
||||
'',
|
||||
'The latest user message approved a final course plan for immediate execution.',
|
||||
'Implement that exact plan directly without asking the user to restate it.',
|
||||
'Treat this approved plan as the canonical source if it differs from any earlier draft.',
|
||||
`Approved plan JSON:\n${JSON.stringify(approvedPlan, null, 2)}`
|
||||
].join('\n')
|
||||
: ''
|
||||
].join('\n');
|
||||
const agentTools = buildAgentTools(orgId, user.id, courseId);
|
||||
|
||||
const trimmedMessages = trimMessageHistory(messages);
|
||||
const modelMessages = await convertToModelMessages(trimmedMessages);
|
||||
let completedStepCount = 0;
|
||||
let finishReason: string | undefined;
|
||||
|
||||
const isAnthropic = providerConfig.provider === AIProvider.ANTHROPIC;
|
||||
const systemContent = isAnthropic
|
||||
? {
|
||||
role: 'system' as const,
|
||||
content: systemPrompt,
|
||||
providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } } }
|
||||
}
|
||||
: systemPrompt;
|
||||
|
||||
const result = streamText({
|
||||
model,
|
||||
maxRetries: 0,
|
||||
system: systemContent,
|
||||
messages: modelMessages,
|
||||
tools: agentTools,
|
||||
stopWhen: stepCountIs(MAX_STEPS_PER_ROUND),
|
||||
onStepFinish: () => {
|
||||
completedStepCount += 1;
|
||||
},
|
||||
onFinish: async ({ totalUsage, finishReason: resultFinishReason, steps }) => {
|
||||
completedStepCount = steps.length;
|
||||
finishReason = resultFinishReason;
|
||||
const durationMs = Date.now() - startTime;
|
||||
const inputTokens = totalUsage?.inputTokens ?? 0;
|
||||
const outputTokens = totalUsage?.outputTokens ?? 0;
|
||||
|
||||
if (totalUsage) {
|
||||
await recordTokenUsage(
|
||||
orgId,
|
||||
user.id,
|
||||
courseId,
|
||||
{
|
||||
promptTokens: inputTokens,
|
||||
completionTokens: outputTokens,
|
||||
totalTokens: inputTokens + outputTokens
|
||||
},
|
||||
providerConfig.model || providerConfig.provider
|
||||
);
|
||||
}
|
||||
|
||||
trackAgentEvent(AgentEvent.CHAT_COMPLETED, {
|
||||
orgId,
|
||||
userId: user.id,
|
||||
courseId,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
model: providerConfig.model || providerConfig.provider,
|
||||
durationMs
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return result.toUIMessageStreamResponse({
|
||||
messageMetadata: ({ part }) => {
|
||||
if (part.type !== 'finish') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
tokenUsage: {
|
||||
promptTokens: part.totalUsage.inputTokens,
|
||||
completionTokens: part.totalUsage.outputTokens,
|
||||
totalTokens: part.totalUsage.totalTokens
|
||||
},
|
||||
continuation:
|
||||
completedStepCount >= MAX_STEPS_PER_ROUND
|
||||
? {
|
||||
reason: 'step_limit' as const,
|
||||
maxSteps: MAX_STEPS_PER_ROUND,
|
||||
finishReason
|
||||
}
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
trackAgentEvent(AgentEvent.CHAT_ERROR, {
|
||||
orgId,
|
||||
userId: user?.id,
|
||||
errorMessage: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
|
||||
if (error instanceof AppError) {
|
||||
return c.json({ success: false, error: error.message, code: error.code }, error.statusCode);
|
||||
}
|
||||
|
||||
console.error('Agent chat error:', error);
|
||||
return c.json({ success: false, error: 'Failed to process chat message', code: 'INTERNAL_ERROR' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export const agentRouter = new Hono().route('/', agentCoreRouter).route('/history', agentHistoryRouter);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,35 +0,0 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -28,7 +28,7 @@ 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) => {
|
||||
.get('/', authMiddleware, orgMemberMiddleware, zValidator('query', ZAgentHistoryQuery), async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const { courseId } = c.req.valid('query');
|
||||
@@ -46,7 +46,7 @@ export const agentHistoryRouter = new Hono()
|
||||
* Get a single conversation with its messages.
|
||||
*/
|
||||
.get(
|
||||
'/history/:conversationId',
|
||||
'/:conversationId',
|
||||
authMiddleware,
|
||||
orgMemberMiddleware,
|
||||
zValidator('param', ZAgentConversationParam),
|
||||
@@ -72,31 +72,25 @@ export const agentHistoryRouter = new Hono()
|
||||
* 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');
|
||||
.post('/', 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);
|
||||
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');
|
||||
}
|
||||
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',
|
||||
'/:conversationId',
|
||||
authMiddleware,
|
||||
orgMemberMiddleware,
|
||||
zValidator('param', ZAgentConversationParam),
|
||||
@@ -121,7 +115,7 @@ export const agentHistoryRouter = new Hono()
|
||||
* Delete a conversation.
|
||||
*/
|
||||
.delete(
|
||||
'/history/:conversationId',
|
||||
'/:conversationId',
|
||||
authMiddleware,
|
||||
orgMemberMiddleware,
|
||||
zValidator('param', ZAgentHistoryDeleteParam),
|
||||
@@ -144,7 +138,7 @@ export const agentHistoryRouter = new Hono()
|
||||
* Generate a short title from the first user message using a cheap model.
|
||||
*/
|
||||
.post(
|
||||
'/history/:conversationId/generate-title',
|
||||
'/:conversationId/generate-title',
|
||||
authMiddleware,
|
||||
orgMemberMiddleware,
|
||||
zValidator('param', ZAgentGenerateTitleParam),
|
||||
@@ -160,7 +154,12 @@ export const agentHistoryRouter = new Hono()
|
||||
throw new AppError('AI assistant is not configured', 'AI_NOT_CONFIGURED', 503);
|
||||
}
|
||||
|
||||
const title = await generateConversationTitle(firstMessageText, providerConfig);
|
||||
let title: string;
|
||||
try {
|
||||
title = await generateConversationTitle(firstMessageText, providerConfig);
|
||||
} catch {
|
||||
title = firstMessageText.slice(0, 60).trim();
|
||||
}
|
||||
|
||||
await updateConversationTitle(conversationId, user.id, title);
|
||||
|
||||
|
||||
@@ -1,6 +1 @@
|
||||
export { agentStatusRouter } from './status';
|
||||
export { agentChatRouter } from './chat';
|
||||
export { agentUploadRouter } from './upload';
|
||||
export { agentUsageRouter } from './usage';
|
||||
export { agentCreditsRouter } from './credits';
|
||||
export { agentHistoryRouter } from './history';
|
||||
export { agentRouter } from './agent';
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -1,91 +0,0 @@
|
||||
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');
|
||||
}
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
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');
|
||||
}
|
||||
});
|
||||
@@ -5,7 +5,11 @@ import {
|
||||
ZExerciseGetParam,
|
||||
ZExerciseListQuery,
|
||||
ZExerciseSubmissionCreate,
|
||||
ZExerciseUpdate
|
||||
ZExerciseUpdate,
|
||||
ZExerciseVideoRecordingParam,
|
||||
ZExerciseVideoRecordingPlaybackParam,
|
||||
ZExerciseVideoRecordingUploadComplete,
|
||||
ZExerciseVideoRecordingUploadInit
|
||||
} from '@cio/utils/validation/exercise';
|
||||
import { ZTemplateById, ZTemplateByTag } from '@cio/utils/validation/mocks';
|
||||
import {
|
||||
@@ -19,6 +23,11 @@ import {
|
||||
} from '@api/services/exercise/exercise';
|
||||
import { fetchAllTemplatesMetadata, fetchTemplateById, fetchTemplatesByTag } from '@api/services/exercise/template';
|
||||
import { getGroupMemberIdByCourseAndProfile, isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group';
|
||||
import {
|
||||
completeVideoRecordingUpload,
|
||||
getVideoRecordingPlaybackUrl,
|
||||
initVideoRecordingUpload
|
||||
} from '@api/services/exercise/video-recording';
|
||||
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { authMiddleware } from '@api/middlewares/auth';
|
||||
@@ -259,6 +268,67 @@ export const exerciseRouter = new Hono()
|
||||
}
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/:exerciseId/question/:questionId/video-recording/upload/init',
|
||||
authMiddleware,
|
||||
courseMemberMiddleware,
|
||||
zValidator('param', ZExerciseVideoRecordingParam),
|
||||
zValidator('json', ZExerciseVideoRecordingUploadInit),
|
||||
async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const courseId = c.req.param('courseId')!;
|
||||
const { exerciseId, questionId } = c.req.valid('param');
|
||||
const data = c.req.valid('json');
|
||||
|
||||
const upload = await initVideoRecordingUpload(courseId, exerciseId, questionId, user.id, data);
|
||||
|
||||
return c.json({ success: true, data: upload }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to initialize video recording upload');
|
||||
}
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/:exerciseId/question/:questionId/video-recording/upload/complete',
|
||||
authMiddleware,
|
||||
courseMemberMiddleware,
|
||||
zValidator('param', ZExerciseVideoRecordingParam),
|
||||
zValidator('json', ZExerciseVideoRecordingUploadComplete),
|
||||
async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const courseId = c.req.param('courseId')!;
|
||||
const { exerciseId, questionId } = c.req.valid('param');
|
||||
const data = c.req.valid('json');
|
||||
|
||||
const answer = await completeVideoRecordingUpload(courseId, exerciseId, questionId, user.id, data);
|
||||
|
||||
return c.json({ success: true, data: answer }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to complete video recording upload');
|
||||
}
|
||||
}
|
||||
)
|
||||
.get(
|
||||
'/:exerciseId/submission/:submissionId/question/:questionId/video-recording/playback',
|
||||
authMiddleware,
|
||||
courseMemberMiddleware,
|
||||
zValidator('param', ZExerciseVideoRecordingPlaybackParam),
|
||||
async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const courseId = c.req.param('courseId')!;
|
||||
const { exerciseId, submissionId, questionId } = c.req.valid('param');
|
||||
|
||||
const playback = await getVideoRecordingPlaybackUrl(courseId, exerciseId, submissionId, questionId, user.id);
|
||||
|
||||
return c.json({ success: true, data: playback }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to get video recording playback URL');
|
||||
}
|
||||
}
|
||||
)
|
||||
// Template routes
|
||||
.get('/template', authMiddleware, courseMemberMiddleware, async (c) => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './public-course';
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ZPublicCourseBySlugParam, ZPublicCourseItemParam } from '@cio/utils/validation/course';
|
||||
import { getPublicCourseItemService, getPublicCourseTreeService } from '@api/services/course/public-course';
|
||||
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { handleError } from '@api/utils/errors';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
|
||||
/**
|
||||
* Anonymous-safe (no auth middleware) routes that back the `(org-site)/course/[slug]`
|
||||
* surface in the dashboard. Every response returns a single typed shape per the
|
||||
* repo's single-response-shape rule; the item endpoint uses a `kind` discriminator.
|
||||
*/
|
||||
export const publicCourseRouter = new Hono()
|
||||
.get('/:courseSlug', zValidator('param', ZPublicCourseBySlugParam), async (c) => {
|
||||
try {
|
||||
const { courseSlug } = c.req.valid('param');
|
||||
const tree = await getPublicCourseTreeService(courseSlug);
|
||||
|
||||
if (!tree) {
|
||||
return c.json(
|
||||
{
|
||||
success: false as const,
|
||||
error: 'Public course not found',
|
||||
code: 'COURSE_NOT_FOUND'
|
||||
},
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
return c.json({ success: true as const, data: tree });
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to load public course');
|
||||
}
|
||||
})
|
||||
.get('/:courseSlug/item/:itemSlug', zValidator('param', ZPublicCourseItemParam), async (c) => {
|
||||
try {
|
||||
const { courseSlug, itemSlug } = c.req.valid('param');
|
||||
const item = await getPublicCourseItemService(courseSlug, itemSlug);
|
||||
|
||||
if (!item) {
|
||||
return c.json(
|
||||
{
|
||||
success: false as const,
|
||||
error: 'Public course item not found',
|
||||
code: 'NOT_FOUND'
|
||||
},
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
return c.json({ success: true as const, data: item });
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to load public course item');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import { z } from 'zod';
|
||||
import { CoursePlanSchema } from '@cio/ai-assistant';
|
||||
import { ZExerciseSectionAfterBehavior } from '@cio/utils/validation/exercise';
|
||||
import { ZCourseLandingPageUpdate, ZCourseLandingPageMetadataUpdate } from '@cio/utils/validation/course';
|
||||
|
||||
// courseId is NOT a parameter — it's injected from the authenticated request context.
|
||||
// This prevents prompt injection from tricking the LLM into targeting another course.
|
||||
|
||||
export const emptyParam = z.object({});
|
||||
export const lessonReadParam = z.object({ lessonId: z.string(), locale: z.string().default('en') });
|
||||
export const exerciseReadParam = z.object({ exerciseId: z.string() });
|
||||
export const createSectionParam = z.object({ title: z.string().min(1), order: z.number().int().min(0) });
|
||||
export const updateSectionParam = z
|
||||
.object({
|
||||
sectionId: z.string(),
|
||||
title: z.string().min(1).optional(),
|
||||
order: z.number().int().min(0).optional()
|
||||
})
|
||||
.refine((data) => data.title !== undefined || data.order !== undefined, {
|
||||
message: 'Provide at least one field to update'
|
||||
});
|
||||
export const createLessonParam = z.object({
|
||||
sectionId: z.string(),
|
||||
title: z.string().min(1),
|
||||
order: z.number().int().min(0)
|
||||
});
|
||||
export const updateLessonParam = z
|
||||
.object({
|
||||
lessonId: z.string(),
|
||||
title: z.string().min(1).optional(),
|
||||
sectionId: z.string().optional(),
|
||||
order: z.number().int().min(0).optional(),
|
||||
lessonAt: z.string().optional(),
|
||||
callUrl: z.string().optional(),
|
||||
isUnlocked: z.boolean().optional(),
|
||||
public: z.boolean().optional()
|
||||
})
|
||||
.refine(
|
||||
(data) =>
|
||||
data.title !== undefined ||
|
||||
data.sectionId !== undefined ||
|
||||
data.order !== undefined ||
|
||||
data.lessonAt !== undefined ||
|
||||
data.callUrl !== undefined ||
|
||||
data.isUnlocked !== undefined ||
|
||||
data.public !== undefined,
|
||||
{
|
||||
message: 'Provide at least one field to update'
|
||||
}
|
||||
);
|
||||
export const updateContentParam = z.object({
|
||||
lessonId: z.string(),
|
||||
locale: z.string().default('en'),
|
||||
content: z.string().min(1)
|
||||
});
|
||||
|
||||
export const questionSchema = z.object({
|
||||
question: z.string().min(1),
|
||||
questionTypeId: z.number().int().min(1).max(14).default(1),
|
||||
points: z.number().min(0).default(1),
|
||||
order: z.number().int().min(0),
|
||||
options: z.array(z.object({ label: z.string().min(1), isCorrect: z.boolean() }))
|
||||
});
|
||||
|
||||
export const createExerciseParam = z.object({
|
||||
lessonId: z.string().optional(),
|
||||
sectionId: z.string().optional(),
|
||||
title: z.string().min(1),
|
||||
description: z.string().optional().describe('Optional short description shown to students above the questions.'),
|
||||
order: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.optional()
|
||||
.describe(
|
||||
'Display order within the section (0-based). Required when sectionId is provided. Lessons and exercises share the same order space within a section.'
|
||||
),
|
||||
questions: z.array(questionSchema)
|
||||
});
|
||||
|
||||
export const updateExerciseParam = z
|
||||
.object({
|
||||
exerciseId: z.string().describe('The exercise to update. Must be a real UUID returned by a prior tool call.'),
|
||||
title: z.string().min(1).optional().describe('New exercise title. Omit to keep unchanged.'),
|
||||
description: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('New short description shown to students. Pass an empty string to clear it.'),
|
||||
lessonId: z.string().optional().describe('Link the exercise to a lesson in this course.'),
|
||||
sectionId: z.string().optional().describe('Move the exercise to a section in this course.'),
|
||||
order: z.number().int().min(0).optional().describe('New order within the section (0-based).'),
|
||||
dueBy: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Due date in ISO 8601 format (e.g. 2026-05-01T23:59:00Z). Omit to keep unchanged.'),
|
||||
isUnlocked: z.boolean().optional().describe('Whether the exercise is unlocked for students.'),
|
||||
allowMultipleAttempts: z.boolean().optional().describe('Whether students can re-take the exercise.')
|
||||
})
|
||||
.refine(
|
||||
(data) =>
|
||||
data.title !== undefined ||
|
||||
data.description !== undefined ||
|
||||
data.lessonId !== undefined ||
|
||||
data.sectionId !== undefined ||
|
||||
data.order !== undefined ||
|
||||
data.dueBy !== undefined ||
|
||||
data.isUnlocked !== undefined ||
|
||||
data.allowMultipleAttempts !== undefined,
|
||||
{
|
||||
message: 'Provide at least one field to update'
|
||||
}
|
||||
);
|
||||
|
||||
export const updateExerciseSectionParam = z
|
||||
.object({
|
||||
exerciseId: z
|
||||
.string()
|
||||
.describe('The exercise that contains this section. Must match the exercise you read with get_exercise_details.'),
|
||||
exerciseSectionId: z
|
||||
.string()
|
||||
.describe('The exercise section id from get_exercise_details `sections[].id` — not a course section id.'),
|
||||
title: z.string().min(1).optional().describe('New section heading shown above questions in this block.'),
|
||||
description: z.string().optional().describe('Optional intro HTML for this block. Pass an empty string to clear it.')
|
||||
})
|
||||
.refine((data) => data.title !== undefined || data.description !== undefined, {
|
||||
message: 'Provide at least title or description to update'
|
||||
});
|
||||
|
||||
export const createExerciseSectionParam = z.object({
|
||||
exerciseId: z.string().describe('The exercise that will contain this section.'),
|
||||
title: z.string().min(1).describe('Section heading shown above the questions in this block.'),
|
||||
description: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Optional intro HTML for this block. Pass an empty string to leave it blank.'),
|
||||
order: z.number().int().min(0).describe('Display order within the exercise (0-based).'),
|
||||
colorTheme: z.enum(['blue', 'green', 'amber', 'rose', 'violet', 'slate']).optional(),
|
||||
afterBehavior: ZExerciseSectionAfterBehavior.optional()
|
||||
});
|
||||
|
||||
export const addQuestionsParam = z.object({
|
||||
exerciseId: z.string(),
|
||||
exerciseSectionId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe(
|
||||
'In-exercise section id from get_exercise_details `sections[].id` where the new questions should appear (not a course outline section id).'
|
||||
),
|
||||
questions: z.array(questionSchema)
|
||||
});
|
||||
|
||||
export const updateOptionSchema = z.object({
|
||||
id: z.number().int().optional(),
|
||||
label: z.string().min(1).optional(),
|
||||
isCorrect: z.boolean().optional(),
|
||||
settings: z.record(z.string(), z.unknown()).optional()
|
||||
});
|
||||
|
||||
export const updateQuestionPatchSchema = z.object({
|
||||
id: z.number().int(),
|
||||
question: z.string().min(1).optional(),
|
||||
questionTypeId: z.number().int().min(1).max(14).optional(),
|
||||
points: z.number().min(0).optional(),
|
||||
order: z.number().int().min(0).optional(),
|
||||
exerciseSectionId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
'Move the question to this in-exercise block (get_exercise_details sections[].id). Use null to clear section assignment.'
|
||||
),
|
||||
settings: z.record(z.string(), z.unknown()).optional(),
|
||||
options: z.array(updateOptionSchema).optional()
|
||||
});
|
||||
|
||||
export const updateQuestionsParam = z.object({
|
||||
exerciseId: z.string(),
|
||||
questions: z.array(updateQuestionPatchSchema).min(1)
|
||||
});
|
||||
export const coursePlanParam = z.object({ plan: CoursePlanSchema });
|
||||
|
||||
// Gemini's tool-schema validator only accepts string enums, so numeric/boolean
|
||||
// `z.literal` values must be relaxed to plain types in the schemas the model sees.
|
||||
export const agentLessonTabsOrder = z.array(
|
||||
z.object({
|
||||
id: z.number().int().min(1).max(4),
|
||||
name: z.string()
|
||||
})
|
||||
);
|
||||
export const agentLandingPageMetadataUpdate = ZCourseLandingPageMetadataUpdate.extend({
|
||||
lessonTabsOrder: agentLessonTabsOrder.optional()
|
||||
});
|
||||
export const updateCourseLandingPageParam = ZCourseLandingPageUpdate.extend({
|
||||
metadata: agentLandingPageMetadataUpdate.optional()
|
||||
}).refine(
|
||||
(data) =>
|
||||
data.title !== undefined ||
|
||||
data.description !== undefined ||
|
||||
data.overview !== undefined ||
|
||||
data.cost !== undefined ||
|
||||
data.currency !== undefined ||
|
||||
data.imageUrl !== undefined ||
|
||||
data.generateImage !== undefined ||
|
||||
data.imageQuery !== undefined ||
|
||||
data.metadata !== undefined,
|
||||
{
|
||||
message: 'Provide at least one landing-page field to update'
|
||||
}
|
||||
);
|
||||
export const goLiveParam = z.object({
|
||||
confirmPublish: z
|
||||
.boolean()
|
||||
.describe('Must be true. Only use this after the teacher explicitly asks to publish or go live.')
|
||||
});
|
||||
|
||||
export const reorderContentParam = z.object({
|
||||
sections: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
order: z.number().int().min(0)
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
.describe('Reorder sections by setting new order values'),
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
type: z.enum(['LESSON', 'EXERCISE']),
|
||||
order: z.number().int().min(0).optional().describe('New order within the section'),
|
||||
sectionId: z.string().nullable().optional().describe('Move item to a different section')
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
.describe('Reorder or move lessons/exercises')
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { AppError } from '@api/utils/errors';
|
||||
import { getDocumentText } from '@api/services/agent/document';
|
||||
import { redis } from '@api/utils/redis/redis';
|
||||
import { getCourseSectionBinding, getExerciseCourseBinding, getLessonCourseBinding } from '@cio/db/queries/agent';
|
||||
import { z } from 'zod';
|
||||
import { CoursePlanSchema } from '@cio/ai-assistant';
|
||||
|
||||
type ResourceOwnershipRow = {
|
||||
courseId: string | null;
|
||||
title?: string | null;
|
||||
};
|
||||
|
||||
function buildResourceOwnershipError(params: {
|
||||
resourceType: 'Lesson' | 'Exercise' | 'Section';
|
||||
resourceId: string;
|
||||
courseId: string;
|
||||
resource?: ResourceOwnershipRow;
|
||||
}) {
|
||||
const { resourceType, resourceId, resource } = params;
|
||||
|
||||
if (!resource) {
|
||||
return new AppError(
|
||||
`${resourceType} ${resourceId} does not exist in this course. The ID may have been hallucinated — call get_course_structure to fetch real IDs and retry.`,
|
||||
'RESOURCE_NOT_IN_COURSE',
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
const titleSuffix = resource.title ? ` (${resource.title})` : '';
|
||||
|
||||
return new AppError(
|
||||
`${resourceType} ${resourceId}${titleSuffix} belongs to a different course. Call get_course_structure to fetch IDs for the current course and retry.`,
|
||||
'RESOURCE_NOT_IN_COURSE',
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function assertValidUuid(
|
||||
resourceType: 'Lesson' | 'Exercise' | 'Section' | 'ExerciseSection',
|
||||
value: string
|
||||
): void {
|
||||
if (!UUID_REGEX.test(value)) {
|
||||
throw new AppError(
|
||||
`${resourceType} id "${value}" is not a valid UUID. Call get_course_structure to fetch real IDs and try again — never invent or guess UUIDs.`,
|
||||
'INVALID_RESOURCE_ID',
|
||||
400
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyLessonBelongsToCourse(lessonId: string, courseId: string): Promise<void> {
|
||||
assertValidUuid('Lesson', lessonId);
|
||||
|
||||
const lesson = await getLessonCourseBinding(lessonId);
|
||||
|
||||
if (!lesson || lesson.courseId !== courseId) {
|
||||
throw buildResourceOwnershipError({
|
||||
resourceType: 'Lesson',
|
||||
resourceId: lessonId,
|
||||
courseId,
|
||||
resource: lesson
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyExerciseBelongsToCourse(exerciseId: string, courseId: string): Promise<void> {
|
||||
assertValidUuid('Exercise', exerciseId);
|
||||
|
||||
const exercise = await getExerciseCourseBinding(exerciseId);
|
||||
|
||||
if (!exercise || exercise.courseId !== courseId) {
|
||||
throw buildResourceOwnershipError({
|
||||
resourceType: 'Exercise',
|
||||
resourceId: exerciseId,
|
||||
courseId,
|
||||
resource: exercise
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifySectionBelongsToCourse(sectionId: string, courseId: string): Promise<void> {
|
||||
assertValidUuid('Section', sectionId);
|
||||
|
||||
const section = await getCourseSectionBinding(sectionId);
|
||||
|
||||
if (!section || section.courseId !== courseId) {
|
||||
throw buildResourceOwnershipError({
|
||||
resourceType: 'Section',
|
||||
resourceId: sectionId,
|
||||
courseId,
|
||||
resource: section
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type AttachedMessage = { metadata?: { attachment?: { documentId?: string } } };
|
||||
|
||||
export function collectDocumentIds(messages: unknown[], currentDocumentId?: string): string[] {
|
||||
const ids = new Set<string>();
|
||||
|
||||
for (const msg of messages as AttachedMessage[]) {
|
||||
const id = msg?.metadata?.attachment?.documentId;
|
||||
|
||||
if (id) ids.add(id);
|
||||
}
|
||||
|
||||
if (currentDocumentId) ids.add(currentDocumentId);
|
||||
|
||||
return Array.from(ids);
|
||||
}
|
||||
|
||||
export async function loadDocumentsText(documentIds: string[], userId: string): Promise<string | undefined> {
|
||||
const loaded = await Promise.all(
|
||||
documentIds.map(async (id) => {
|
||||
const text = await getDocumentText(id, userId, redis);
|
||||
|
||||
return text ? { id, text } : null;
|
||||
})
|
||||
);
|
||||
|
||||
const sections = loaded
|
||||
.filter((d): d is { id: string; text: string } => d !== null)
|
||||
.map((d, i) => `--- Document ${i + 1} (id: ${d.id}) ---\n${d.text}`);
|
||||
|
||||
return sections.length > 0 ? sections.join('\n\n') : undefined;
|
||||
}
|
||||
|
||||
type PlanMetadataMessage = {
|
||||
role?: string;
|
||||
metadata?: {
|
||||
plan?: {
|
||||
action?: string;
|
||||
payload?: unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export function getLatestImplementationPlan(messages: unknown[]): z.infer<typeof CoursePlanSchema> | undefined {
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
const message = messages[index] as PlanMetadataMessage;
|
||||
|
||||
if (message?.role !== 'user') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message?.metadata?.plan?.action !== 'implement_course_plan') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsedPlan = CoursePlanSchema.safeParse(message.metadata.plan.payload);
|
||||
|
||||
if (parsedPlan.success) {
|
||||
return parsedPlan.data;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
import { tool } from 'ai';
|
||||
import { CoursePlanSchema } from '@cio/ai-assistant';
|
||||
import { AppError } from '@api/utils/errors';
|
||||
import { trackAgentEvent, AgentEvent } from '@api/utils/tinybird';
|
||||
import { getCourseContentItems } from '@cio/db/queries/course/content';
|
||||
import { getExerciseSectionsByExerciseId } from '@cio/db/queries/exercise';
|
||||
import type { TCourseLandingPageUpdate } from '@cio/utils/validation/course';
|
||||
import { listCourseSections, createCourseSection, updateCourseSectionService } from '@api/services/course/section';
|
||||
import { createLesson, getLesson, updateLessonService } from '@api/services/lesson/lesson';
|
||||
import { upsertLessonLanguageService } from '@api/services/lesson-language';
|
||||
import {
|
||||
createExercise,
|
||||
getExercise,
|
||||
createExerciseSectionService,
|
||||
updateExerciseService,
|
||||
updateExerciseSectionMetadataService
|
||||
} from '@api/services/exercise/exercise';
|
||||
import { reorderCourseContent } from '@api/services/course/content';
|
||||
import { normalizeAgentLessonContent } from '@api/services/agent/lesson-content';
|
||||
import { buildUpdatedQuestions } from '@api/services/agent/question-update';
|
||||
import { updateCourseLandingPageService } from '@api/services/course/landing-page';
|
||||
import { getCourseGoLiveReadiness, publishCourseWhenReady } from '@api/services/course/go-live-readiness';
|
||||
import {
|
||||
assertValidUuid,
|
||||
verifyExerciseBelongsToCourse,
|
||||
verifyLessonBelongsToCourse,
|
||||
verifySectionBelongsToCourse
|
||||
} from '@api/services/agent/chat-context';
|
||||
import {
|
||||
addQuestionsParam,
|
||||
coursePlanParam,
|
||||
createExerciseParam,
|
||||
createExerciseSectionParam,
|
||||
createLessonParam,
|
||||
createSectionParam,
|
||||
emptyParam,
|
||||
exerciseReadParam,
|
||||
goLiveParam,
|
||||
lessonReadParam,
|
||||
reorderContentParam,
|
||||
updateContentParam,
|
||||
updateCourseLandingPageParam,
|
||||
updateExerciseParam,
|
||||
updateExerciseSectionParam,
|
||||
updateLessonParam,
|
||||
updateQuestionsParam,
|
||||
updateSectionParam
|
||||
} from '@api/services/agent/agent-tool-schemas';
|
||||
|
||||
function summarizeAgentDebugValue(value: unknown, depth = 0): unknown {
|
||||
if (value == null) return value;
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value.length > 200 ? `${value.slice(0, 200)}… (${value.length} chars)` : value;
|
||||
}
|
||||
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (depth >= 2) {
|
||||
return `[Array(${value.length})]`;
|
||||
}
|
||||
|
||||
return {
|
||||
count: value.length,
|
||||
items: value.slice(0, 3).map((item) => summarizeAgentDebugValue(item, depth + 1))
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
if (depth >= 2) {
|
||||
return '[Object]';
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).map(([key, nestedValue]) => [
|
||||
key,
|
||||
summarizeAgentDebugValue(nestedValue, depth + 1)
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function logAgentToolDebug(
|
||||
phase: 'start' | 'success' | 'error',
|
||||
toolName: string,
|
||||
details: { courseId: string; userId: string; args?: unknown; result?: unknown; error?: unknown }
|
||||
) {
|
||||
const base = `[agent-tool:${phase}] ${toolName}`;
|
||||
|
||||
if (phase === 'error') {
|
||||
console.error(base, {
|
||||
courseId: details.courseId,
|
||||
userId: details.userId,
|
||||
args: summarizeAgentDebugValue(details.args),
|
||||
error:
|
||||
details.error instanceof Error
|
||||
? {
|
||||
message: details.error.message,
|
||||
stack: details.error.stack
|
||||
}
|
||||
: summarizeAgentDebugValue(details.error)
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.info(base, {
|
||||
courseId: details.courseId,
|
||||
userId: details.userId,
|
||||
args: summarizeAgentDebugValue(details.args),
|
||||
result: summarizeAgentDebugValue(details.result)
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeToolError(toolName: string, error: unknown): Error {
|
||||
if (error instanceof AppError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
const raw = error instanceof Error ? error.message : String(error);
|
||||
const looksLikeSqlLeak =
|
||||
/^Failed query:/i.test(raw) ||
|
||||
/\bparams:\s/i.test(raw) ||
|
||||
/\binvalid input syntax for type uuid\b/i.test(raw) ||
|
||||
/\bselect\s+.+\bfrom\s+/i.test(raw);
|
||||
|
||||
if (looksLikeSqlLeak) {
|
||||
return new Error(
|
||||
`${toolName} failed because of an invalid or unknown ID. Call get_course_structure to fetch the current IDs and retry — never invent UUIDs.`
|
||||
);
|
||||
}
|
||||
|
||||
return error instanceof Error ? error : new Error(raw);
|
||||
}
|
||||
|
||||
async function executeAgentTool<TArgs, TResult>(
|
||||
toolName: string,
|
||||
params: { orgId: string; userId: string; courseId: string; args?: TArgs },
|
||||
execute: () => Promise<TResult>
|
||||
): Promise<TResult> {
|
||||
trackAgentEvent(AgentEvent.TOOL_CALLED, {
|
||||
orgId: params.orgId,
|
||||
userId: params.userId,
|
||||
courseId: params.courseId,
|
||||
toolName
|
||||
});
|
||||
logAgentToolDebug('start', toolName, {
|
||||
courseId: params.courseId,
|
||||
userId: params.userId,
|
||||
args: params.args
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await execute();
|
||||
|
||||
trackAgentEvent(AgentEvent.TOOL_COMPLETED, {
|
||||
orgId: params.orgId,
|
||||
userId: params.userId,
|
||||
courseId: params.courseId,
|
||||
toolName,
|
||||
success: true
|
||||
});
|
||||
logAgentToolDebug('success', toolName, {
|
||||
courseId: params.courseId,
|
||||
userId: params.userId,
|
||||
args: params.args,
|
||||
result
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logAgentToolDebug('error', toolName, {
|
||||
courseId: params.courseId,
|
||||
userId: params.userId,
|
||||
args: params.args,
|
||||
error
|
||||
});
|
||||
|
||||
throw sanitizeToolError(toolName, error);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAgentTools(orgId: string, userId: string, courseId: string) {
|
||||
return {
|
||||
get_course_structure: tool({
|
||||
description:
|
||||
'Get the full course structure including sections, lessons, and exercises as a tree. The courseId is automatically set — do not pass it.',
|
||||
inputSchema: emptyParam,
|
||||
execute: async () => {
|
||||
return executeAgentTool('get_course_structure', { orgId, userId, courseId }, async () => {
|
||||
const [items, sections] = await Promise.all([getCourseContentItems(courseId), listCourseSections(courseId)]);
|
||||
return { sections, items };
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
get_lesson_content: tool({
|
||||
description: 'Get the HTML content of a specific lesson in this course.',
|
||||
inputSchema: lessonReadParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('get_lesson_content', { orgId, userId, courseId, args }, async () => {
|
||||
await verifyLessonBelongsToCourse(args.lessonId, courseId);
|
||||
const lesson = await getLesson(args.lessonId);
|
||||
const lessonWithLangs = lesson as {
|
||||
id: string;
|
||||
title: string;
|
||||
lessonLanguages?: Array<{ locale: string; content: string | null }>;
|
||||
};
|
||||
const langContent = lessonWithLangs.lessonLanguages?.find((ll) => ll.locale === args.locale);
|
||||
return { id: lesson.id, title: lesson.title, content: langContent?.content || null, locale: args.locale };
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
get_exercise_details: tool({
|
||||
description:
|
||||
'Get an exercise with sections, questions, and answer options from this course. Each sections entry has an id — use it with update_exercise_section to change that question block title or description.',
|
||||
inputSchema: exerciseReadParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('get_exercise_details', { orgId, userId, courseId, args }, async () => {
|
||||
await verifyExerciseBelongsToCourse(args.exerciseId, courseId);
|
||||
return getExercise(args.exerciseId);
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
create_section: tool({
|
||||
description: 'Create a new section in this course.',
|
||||
inputSchema: createSectionParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('create_section', { orgId, userId, courseId, args }, async () => {
|
||||
const section = await createCourseSection(courseId, { title: args.title, courseId, order: args.order });
|
||||
return { id: section.id, title: section.title, order: section.order };
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
update_section: tool({
|
||||
description:
|
||||
'Update metadata for an existing section in this course. Use this to rename a section or change its order instead of creating a new section.',
|
||||
inputSchema: updateSectionParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('update_section', { orgId, userId, courseId, args }, async () => {
|
||||
await verifySectionBelongsToCourse(args.sectionId, courseId);
|
||||
const section = await updateCourseSectionService(args.sectionId, {
|
||||
...(args.title !== undefined ? { title: args.title } : {}),
|
||||
...(args.order !== undefined ? { order: args.order } : {})
|
||||
});
|
||||
|
||||
return { id: section.id, title: section.title, updated: true };
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
create_lesson: tool({
|
||||
description:
|
||||
'Create a new lesson within a section of this course. Use update_lesson_content after to add content.',
|
||||
inputSchema: createLessonParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('create_lesson', { orgId, userId, courseId, args }, async () => {
|
||||
await verifySectionBelongsToCourse(args.sectionId, courseId);
|
||||
const lesson = await createLesson(courseId, {
|
||||
title: args.title,
|
||||
courseId,
|
||||
sectionId: args.sectionId,
|
||||
order: args.order
|
||||
});
|
||||
return { id: lesson.id, title: lesson.title, order: lesson.order };
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
update_lesson: tool({
|
||||
description:
|
||||
'Update metadata for an existing lesson in this course. Use this to rename, move, reorder, schedule, or change visibility/unlock settings instead of creating a new lesson.',
|
||||
inputSchema: updateLessonParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('update_lesson', { orgId, userId, courseId, args }, async () => {
|
||||
if (args.sectionId) {
|
||||
await verifySectionBelongsToCourse(args.sectionId, courseId);
|
||||
}
|
||||
|
||||
await verifyLessonBelongsToCourse(args.lessonId, courseId);
|
||||
const lesson = await updateLessonService(args.lessonId, {
|
||||
...(args.title !== undefined ? { title: args.title } : {}),
|
||||
...(args.sectionId !== undefined ? { sectionId: args.sectionId } : {}),
|
||||
...(args.order !== undefined ? { order: args.order } : {}),
|
||||
...(args.lessonAt !== undefined ? { lessonAt: args.lessonAt } : {}),
|
||||
...(args.callUrl !== undefined ? { callUrl: args.callUrl } : {}),
|
||||
...(args.isUnlocked !== undefined ? { isUnlocked: args.isUnlocked } : {}),
|
||||
...(args.public !== undefined ? { public: args.public } : {})
|
||||
});
|
||||
|
||||
return { id: lesson.id, title: lesson.title, updated: true };
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
update_lesson_content: tool({
|
||||
description:
|
||||
'Update the text content of a lesson in this course. Replaces full lesson HTML for the given locale. For lesson HTML, put only the lesson body in the content. Do not include the lesson title. Do not use h1 or h2 anywhere in lesson HTML. Start headings at h3 because that is the highest heading level allowed in lesson content.',
|
||||
inputSchema: updateContentParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('update_lesson_content', { orgId, userId, courseId, args }, async () => {
|
||||
await verifyLessonBelongsToCourse(args.lessonId, courseId);
|
||||
const lesson = await getLesson(args.lessonId);
|
||||
const normalizedContent = normalizeAgentLessonContent(args.content, lesson.title);
|
||||
|
||||
await upsertLessonLanguageService(args.lessonId, {
|
||||
locale: args.locale as 'en',
|
||||
content: normalizedContent
|
||||
});
|
||||
return {
|
||||
lessonId: args.lessonId,
|
||||
locale: args.locale,
|
||||
contentLength: normalizedContent.length,
|
||||
updated: true
|
||||
};
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
create_exercise: tool({
|
||||
description: 'Create a new exercise with questions and answer options in this course.',
|
||||
inputSchema: createExerciseParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('create_exercise', { orgId, userId, courseId, args }, async () => {
|
||||
if (args.lessonId) {
|
||||
await verifyLessonBelongsToCourse(args.lessonId, courseId);
|
||||
}
|
||||
|
||||
if (args.sectionId) {
|
||||
await verifySectionBelongsToCourse(args.sectionId, courseId);
|
||||
}
|
||||
|
||||
const exercise = await createExercise({
|
||||
title: args.title,
|
||||
description: args.description,
|
||||
courseId,
|
||||
lessonId: args.lessonId,
|
||||
sectionId: args.sectionId,
|
||||
order: args.order,
|
||||
questions: args.questions.map((q, i) => ({
|
||||
question: q.question,
|
||||
questionTypeId: q.questionTypeId,
|
||||
points: q.points,
|
||||
order: q.order ?? i,
|
||||
options: q.options.map((o) => ({ label: o.label, isCorrect: o.isCorrect }))
|
||||
}))
|
||||
});
|
||||
return { id: exercise.id, title: exercise.title, questionCount: args.questions.length };
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
update_exercise: tool({
|
||||
description:
|
||||
"Update an existing exercise's metadata (title, description, linked lesson, section, order, due date, lock state, allow-multiple-attempts). Use this for editing the exercise itself — not for changing its questions. To edit questions, use update_questions; to add questions, use add_questions.",
|
||||
inputSchema: updateExerciseParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('update_exercise', { orgId, userId, courseId, args }, async () => {
|
||||
await verifyExerciseBelongsToCourse(args.exerciseId, courseId);
|
||||
|
||||
if (args.lessonId) {
|
||||
await verifyLessonBelongsToCourse(args.lessonId, courseId);
|
||||
}
|
||||
|
||||
if (args.sectionId) {
|
||||
await verifySectionBelongsToCourse(args.sectionId, courseId);
|
||||
}
|
||||
|
||||
const updated = await updateExerciseService(args.exerciseId, {
|
||||
title: args.title,
|
||||
description: args.description,
|
||||
lessonId: args.lessonId,
|
||||
sectionId: args.sectionId,
|
||||
order: args.order,
|
||||
dueBy: args.dueBy,
|
||||
isUnlocked: args.isUnlocked,
|
||||
allowMultipleAttempts: args.allowMultipleAttempts
|
||||
});
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
title: updated.title,
|
||||
description: updated.description ?? null,
|
||||
dueBy: updated.dueBy ?? null,
|
||||
updated: true
|
||||
};
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
update_exercise_section: tool({
|
||||
description:
|
||||
'Update the title and/or description of one section inside an exercise (question groups). This is not the same as update_section, which edits course outline sections. Always use ids from get_exercise_details `sections` for this tool.',
|
||||
inputSchema: updateExerciseSectionParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('update_exercise_section', { orgId, userId, courseId, args }, async () => {
|
||||
assertValidUuid('Exercise', args.exerciseId);
|
||||
assertValidUuid('ExerciseSection', args.exerciseSectionId);
|
||||
|
||||
await verifyExerciseBelongsToCourse(args.exerciseId, courseId);
|
||||
|
||||
const updated = await updateExerciseSectionMetadataService(args.exerciseId, args.exerciseSectionId, {
|
||||
...(args.title !== undefined ? { title: args.title } : {}),
|
||||
...(args.description !== undefined ? { description: args.description } : {})
|
||||
});
|
||||
|
||||
return { ...updated, updated: true };
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
create_exercise_section: tool({
|
||||
description:
|
||||
'Create a new section inside an existing exercise. Use this to add a question block before adding or moving questions into it.',
|
||||
inputSchema: createExerciseSectionParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('create_exercise_section', { orgId, userId, courseId, args }, async () => {
|
||||
assertValidUuid('Exercise', args.exerciseId);
|
||||
|
||||
await verifyExerciseBelongsToCourse(args.exerciseId, courseId);
|
||||
|
||||
const created = await createExerciseSectionService(args.exerciseId, {
|
||||
title: args.title,
|
||||
description: args.description,
|
||||
order: args.order,
|
||||
colorTheme: args.colorTheme,
|
||||
afterBehavior: args.afterBehavior
|
||||
});
|
||||
|
||||
return { ...created, created: true };
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
add_questions: tool({
|
||||
description:
|
||||
'Add questions to an existing exercise in this course. When get_exercise_details lists in-exercise sections, pass exerciseSectionId so new questions are added to the correct block.',
|
||||
inputSchema: addQuestionsParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('add_questions', { orgId, userId, courseId, args }, async () => {
|
||||
await verifyExerciseBelongsToCourse(args.exerciseId, courseId);
|
||||
|
||||
if (args.exerciseSectionId !== undefined) {
|
||||
assertValidUuid('ExerciseSection', args.exerciseSectionId);
|
||||
|
||||
const sections = await getExerciseSectionsByExerciseId(args.exerciseId);
|
||||
const belongs = sections.some((section) => section.id === args.exerciseSectionId);
|
||||
|
||||
if (!belongs) {
|
||||
throw new AppError(
|
||||
'That exercise section was not found on this exercise. Call get_exercise_details and use a section id from the sections array.',
|
||||
'VALIDATION_ERROR',
|
||||
404
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const existingExercise = await getExercise(args.exerciseId);
|
||||
const existingQuestions = existingExercise.questions || [];
|
||||
const nextOrder = existingQuestions.length;
|
||||
|
||||
const newQuestions = args.questions.map((q, i) => ({
|
||||
question: q.question,
|
||||
questionTypeId: q.questionTypeId,
|
||||
points: q.points,
|
||||
order: q.order ?? nextOrder + i,
|
||||
...(args.exerciseSectionId !== undefined ? { exerciseSectionId: args.exerciseSectionId } : {}),
|
||||
options: q.options.map((o) => ({ label: o.label, isCorrect: o.isCorrect }))
|
||||
}));
|
||||
|
||||
const allQuestions = [
|
||||
...existingQuestions.map((eq) => ({
|
||||
id: Number(eq.id),
|
||||
question: eq.title,
|
||||
questionTypeId: eq.questionTypeId,
|
||||
points: eq.points,
|
||||
order: eq.order,
|
||||
options: eq.options.map((o) => ({ id: Number(o.id), label: o.label || '', isCorrect: o.isCorrect }))
|
||||
})),
|
||||
...newQuestions
|
||||
];
|
||||
|
||||
await updateExerciseService(args.exerciseId, { questions: allQuestions });
|
||||
return { exerciseId: args.exerciseId, addedCount: args.questions.length, totalCount: allQuestions.length };
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
update_questions: tool({
|
||||
description:
|
||||
'Update existing questions in an exercise. Pass only fields you want to change; `id` identifies the question. Optional `exerciseSectionId` moves the question to another in-exercise block (use get_exercise_details section ids), or null to unassign. For NUMERIC, the correct answer is `settings.correctValue` (number) — do NOT add options to NUMERIC questions. For STAR use `settings.correctValue`. For WORD_BANK use `settings.correctAnswers` and `settings.template`. RADIO/CHECKBOX/TRUE_FALSE use `options[].isCorrect` (include option `id` to edit, omit `id` to add). `settings` is shallow-merged with existing settings. Omit `options` entirely to leave existing options untouched.',
|
||||
inputSchema: updateQuestionsParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('update_questions', { orgId, userId, courseId, args }, async () => {
|
||||
await verifyExerciseBelongsToCourse(args.exerciseId, courseId);
|
||||
|
||||
const sectionIdsInPatches = new Set<string>();
|
||||
for (const patch of args.questions) {
|
||||
if (patch.exerciseSectionId === undefined || patch.exerciseSectionId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
assertValidUuid('ExerciseSection', patch.exerciseSectionId);
|
||||
sectionIdsInPatches.add(patch.exerciseSectionId);
|
||||
}
|
||||
|
||||
if (sectionIdsInPatches.size > 0) {
|
||||
const sections = await getExerciseSectionsByExerciseId(args.exerciseId);
|
||||
const validIds = new Set(sections.map((section) => section.id));
|
||||
|
||||
for (const sectionId of sectionIdsInPatches) {
|
||||
if (!validIds.has(sectionId)) {
|
||||
throw new AppError(
|
||||
'That exercise section was not found on this exercise. Call get_exercise_details and use a section id from the sections array.',
|
||||
'VALIDATION_ERROR',
|
||||
404
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await getExercise(args.exerciseId);
|
||||
|
||||
const merged = buildUpdatedQuestions(
|
||||
(existing.questions || []).map((q) => ({
|
||||
id: q.id,
|
||||
title: q.title,
|
||||
questionTypeId: q.questionTypeId,
|
||||
points: q.points,
|
||||
order: q.order,
|
||||
exerciseSectionId: q.exerciseSectionId,
|
||||
settings: q.settings,
|
||||
options: q.options
|
||||
})),
|
||||
args.questions,
|
||||
args.exerciseId
|
||||
);
|
||||
|
||||
await updateExerciseService(args.exerciseId, { questions: merged });
|
||||
|
||||
return { exerciseId: args.exerciseId, updatedCount: merged.length };
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
reorder_content: tool({
|
||||
description:
|
||||
'Reorder sections, lessons, or exercises in this course. Can change the order of sections, change the order of lessons/exercises within a section, or move items between sections. Use get_course_structure first to see current order, and copy item IDs exactly from that response. Never rewrite or guess UUIDs.',
|
||||
inputSchema: reorderContentParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('reorder_content', { orgId, userId, courseId, args }, async () => {
|
||||
if (args.sections) {
|
||||
for (const section of args.sections) {
|
||||
await verifySectionBelongsToCourse(section.id, courseId);
|
||||
}
|
||||
}
|
||||
|
||||
if (args.items) {
|
||||
for (const item of args.items) {
|
||||
if (item.type === 'LESSON') {
|
||||
await verifyLessonBelongsToCourse(item.id, courseId);
|
||||
} else {
|
||||
await verifyExerciseBelongsToCourse(item.id, courseId);
|
||||
}
|
||||
|
||||
if (item.sectionId) {
|
||||
await verifySectionBelongsToCourse(item.sectionId, courseId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return reorderCourseContent(courseId, {
|
||||
sections: args.sections,
|
||||
items: args.items
|
||||
});
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
update_course_landing_page: tool({
|
||||
description:
|
||||
'Update course-level landing page fields for this course, including public title, description, overview, goals, requirements, instructor metadata, pricing, and banner image. The courseId is automatically set — do not pass it.',
|
||||
inputSchema: updateCourseLandingPageParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('update_course_landing_page', { orgId, userId, courseId, args }, async () => {
|
||||
const result = await updateCourseLandingPageService(courseId, args as TCourseLandingPageUpdate);
|
||||
|
||||
return {
|
||||
courseId: result.course.id,
|
||||
title: result.course.title,
|
||||
description: result.course.description,
|
||||
courseUrl: result.courseUrl,
|
||||
bannerImageUrl: result.bannerImageUrl,
|
||||
updated: true
|
||||
};
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
check_course_go_live_readiness: tool({
|
||||
description:
|
||||
'Check whether this course is ready to go live. Returns blockers, warnings, suggested fixes, and the public course URL if available. This tool does not publish the course.',
|
||||
inputSchema: emptyParam,
|
||||
execute: async () => {
|
||||
return executeAgentTool('check_course_go_live_readiness', { orgId, userId, courseId }, async () => {
|
||||
return getCourseGoLiveReadiness(courseId);
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
go_live_course: tool({
|
||||
description:
|
||||
'Publish this course only after the teacher explicitly asks to go live. Runs the readiness checklist first, generates a slug when needed, and fails with blockers if the course is not ready.',
|
||||
inputSchema: goLiveParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('go_live_course', { orgId, userId, courseId, args }, async () => {
|
||||
const result = await publishCourseWhenReady(courseId);
|
||||
|
||||
return {
|
||||
courseId: result.course.id,
|
||||
title: result.course.title,
|
||||
slug: result.course.slug,
|
||||
isPublished: result.course.isPublished,
|
||||
readiness: result.readiness
|
||||
};
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
generate_course_plan: tool({
|
||||
description:
|
||||
'Generate a structured course plan with sections and lessons. Always use this when asked to design or plan a course.',
|
||||
inputSchema: coursePlanParam,
|
||||
execute: async (args) => {
|
||||
return executeAgentTool('generate_course_plan', { orgId, userId, courseId, args }, async () => {
|
||||
const envelope = coursePlanParam.parse(args);
|
||||
const plan = CoursePlanSchema.parse(envelope.plan);
|
||||
const sectionCount = plan.sections.length;
|
||||
const itemCount = plan.sections.reduce((sum, section) => sum + section.items.length, 0);
|
||||
|
||||
trackAgentEvent(AgentEvent.PLAN_GENERATED, { orgId, userId, courseId, sectionCount, itemCount });
|
||||
|
||||
return plan;
|
||||
});
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -1,27 +1,31 @@
|
||||
/**
|
||||
* 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 = 20;
|
||||
const KEEP_RECENT = 16;
|
||||
const MAX_TOOL_OUTPUT_CHARS = 2000;
|
||||
|
||||
const MAX_MESSAGES = 40;
|
||||
const KEEP_RECENT = 30;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function trimToolOutputs(messages: any[]): any[] {
|
||||
return messages.map((message) => {
|
||||
if (!Array.isArray(message.parts)) return message;
|
||||
|
||||
const trimmedParts = message.parts.map((part: any) => {
|
||||
if (part.state !== 'output-available' || part.output == null) return part;
|
||||
|
||||
const serialized = typeof part.output === 'string' ? part.output : JSON.stringify(part.output);
|
||||
if (serialized.length <= MAX_TOOL_OUTPUT_CHARS) return part;
|
||||
|
||||
const truncated = serialized.slice(0, MAX_TOOL_OUTPUT_CHARS) + '…[truncated]';
|
||||
return { ...part, output: truncated };
|
||||
});
|
||||
|
||||
if (trimmedParts === message.parts) return message;
|
||||
|
||||
return { ...message, parts: trimmedParts };
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function trimMessageHistory(messages: any[]): any[] {
|
||||
if (messages.length <= MAX_MESSAGES) {
|
||||
return messages;
|
||||
}
|
||||
const countTrimmed = messages.length <= MAX_MESSAGES ? messages : [messages[0], ...messages.slice(-KEEP_RECENT)];
|
||||
|
||||
const firstMessage = messages[0];
|
||||
const recentMessages = messages.slice(-KEEP_RECENT);
|
||||
|
||||
return [firstMessage, ...recentMessages];
|
||||
return trimToolOutputs(countTrimmed);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { findCreditPurchaseByOrderId, insertCreditPurchaseAndApplyBalance } from '@cio/db/queries/agent';
|
||||
import type { CreditPurchaseInput } from '@cio/db/queries/agent';
|
||||
import { AppError } from '@api/utils/errors';
|
||||
|
||||
/**
|
||||
* Record a Polar token-pack purchase.
|
||||
* Idempotent on `providerOrderId` — Polar may retry the webhook.
|
||||
*/
|
||||
export async function recordCreditPurchase(input: CreditPurchaseInput) {
|
||||
if (input.tokens <= 0) {
|
||||
throw new AppError('Tokens must be positive', 'INVALID_CREDIT_AMOUNT', 400);
|
||||
}
|
||||
|
||||
const existing = await findCreditPurchaseByOrderId(input.providerOrderId);
|
||||
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
return insertCreditPurchaseAndApplyBalance(input);
|
||||
}
|
||||
@@ -107,6 +107,7 @@ export async function parseAndStoreDocument(
|
||||
text: extractedText,
|
||||
fileName: file.name,
|
||||
mimeType,
|
||||
userId,
|
||||
uploadedAt: new Date().toISOString()
|
||||
}),
|
||||
{ EX: DOCUMENT_REDIS_TTL }
|
||||
@@ -150,16 +151,18 @@ export async function parseAndStoreDocument(
|
||||
* 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> {
|
||||
export async function getDocumentText(documentId: string, userId: string, redis: RedisClient): Promise<string | null> {
|
||||
const raw = await redis.get(agentDocumentKey(documentId));
|
||||
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as { text: string };
|
||||
const parsed = JSON.parse(raw) as { text: string; userId?: string };
|
||||
|
||||
if (parsed.userId && parsed.userId !== userId) return null;
|
||||
|
||||
return parsed.text;
|
||||
}
|
||||
|
||||
const record = await getChatDocument(documentId);
|
||||
const record = await getChatDocument(documentId, userId);
|
||||
|
||||
if (!record) return null;
|
||||
|
||||
@@ -169,6 +172,7 @@ export async function getDocumentText(documentId: string, redis: RedisClient): P
|
||||
text: record.text,
|
||||
fileName: record.fileName,
|
||||
mimeType: record.mimeType,
|
||||
userId: record.userId,
|
||||
uploadedAt: record.createdAt
|
||||
}),
|
||||
{ EX: DOCUMENT_REDIS_TTL }
|
||||
|
||||
@@ -6,6 +6,7 @@ export type CurrentQuestionForPatch = {
|
||||
questionTypeId: number;
|
||||
points: number;
|
||||
order: number;
|
||||
exerciseSectionId?: string | null;
|
||||
settings?: Record<string, unknown>;
|
||||
options: Array<{
|
||||
id: number | string;
|
||||
@@ -21,6 +22,7 @@ export type QuestionPatch = {
|
||||
questionTypeId?: number;
|
||||
points?: number;
|
||||
order?: number;
|
||||
exerciseSectionId?: string | null;
|
||||
settings?: Record<string, unknown>;
|
||||
options?: Array<{
|
||||
id?: number;
|
||||
@@ -43,6 +45,7 @@ export type MergedQuestion = {
|
||||
questionTypeId: number;
|
||||
points: number;
|
||||
order: number;
|
||||
exerciseSectionId?: string | null;
|
||||
settings?: Record<string, unknown>;
|
||||
options: MergedOption[];
|
||||
};
|
||||
@@ -97,7 +100,7 @@ export function buildUpdatedQuestions(
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
const merged: MergedQuestion = {
|
||||
id: patch.id,
|
||||
question: patch.question ?? current.title ?? '',
|
||||
questionTypeId: patch.questionTypeId ?? current.questionTypeId,
|
||||
@@ -106,5 +109,11 @@ export function buildUpdatedQuestions(
|
||||
settings: mergedSettings,
|
||||
options: mergedOptions
|
||||
};
|
||||
|
||||
if (patch.exerciseSectionId !== undefined) {
|
||||
merged.exerciseSectionId = patch.exerciseSectionId;
|
||||
}
|
||||
|
||||
return merged;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { generateText } from 'ai';
|
||||
import { generateText, generateObject } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { AIProvider, type AIProviderConfig } from '@cio/ai-assistant';
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { createAnthropic } from '@ai-sdk/anthropic';
|
||||
@@ -9,8 +10,9 @@ import { createGoogleGenerativeAI } from '@ai-sdk/google';
|
||||
*/
|
||||
const TITLE_MODELS: Record<AIProvider, string> = {
|
||||
[AIProvider.OPENAI]: 'gpt-4o-mini',
|
||||
[AIProvider.ANTHROPIC]: 'claude-haiku-4-20250414',
|
||||
[AIProvider.GOOGLE]: 'gemini-2.5-flash'
|
||||
[AIProvider.ANTHROPIC]: 'claude-haiku-4-5-20251001',
|
||||
[AIProvider.GOOGLE]: 'gemini-2.5-flash',
|
||||
[AIProvider.MOONSHOT]: 'kimi-k2.6'
|
||||
};
|
||||
|
||||
function createTitleModel(config: AIProviderConfig) {
|
||||
@@ -29,6 +31,10 @@ function createTitleModel(config: AIProviderConfig) {
|
||||
const google = createGoogleGenerativeAI({ apiKey: config.apiKey });
|
||||
return google(modelName);
|
||||
}
|
||||
case AIProvider.MOONSHOT: {
|
||||
const moonshot = createOpenAI({ apiKey: config.apiKey, baseURL: 'https://api.moonshot.cn/v1' });
|
||||
return moonshot(modelName);
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported AI provider: ${config.provider}`);
|
||||
}
|
||||
@@ -44,13 +50,58 @@ export async function generateConversationTitle(
|
||||
): 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
|
||||
});
|
||||
const systemPrompt =
|
||||
"Generate a short, descriptive title (2–5 words) for a chat conversation based on the user's first message. The title must be at least 2 words — never a single word. Return ONLY the title, no quotes, no punctuation at the end.";
|
||||
|
||||
const truncatedMessage = firstMessageText.slice(0, 1000);
|
||||
|
||||
// Gemini 2.5 counts thinking toward maxOutputTokens; disable thinking and allow headroom for the title text.
|
||||
const { text } = await generateText(
|
||||
providerConfig.provider === AIProvider.GOOGLE
|
||||
? {
|
||||
model,
|
||||
system: systemPrompt,
|
||||
prompt: truncatedMessage,
|
||||
maxOutputTokens: 256,
|
||||
maxRetries: 0,
|
||||
providerOptions: {
|
||||
google: { thinkingConfig: { thinkingBudget: 0 } }
|
||||
}
|
||||
}
|
||||
: {
|
||||
model,
|
||||
system: systemPrompt,
|
||||
prompt: truncatedMessage,
|
||||
maxOutputTokens: 96,
|
||||
maxRetries: 0
|
||||
}
|
||||
);
|
||||
|
||||
return text.trim().slice(0, 80);
|
||||
}
|
||||
|
||||
const CourseMeta = z.object({
|
||||
title: z.string().describe('3–6 word course title, no quotes, no trailing punctuation'),
|
||||
description: z.string().describe('20–30 word description of what students will learn')
|
||||
});
|
||||
|
||||
export async function generateCourseMeta(
|
||||
prompt: string,
|
||||
providerConfig: AIProviderConfig
|
||||
): Promise<{ title: string; description: string }> {
|
||||
const model = createTitleModel(providerConfig);
|
||||
|
||||
const { object } = await generateObject({
|
||||
model,
|
||||
schema: CourseMeta,
|
||||
system:
|
||||
"You are helping create an online course. Based on the user's course idea, generate a short title (3–6 words, no quotes, no trailing punctuation) and a concise description (20–30 words) explaining what students will learn.",
|
||||
prompt: prompt.slice(0, 1000),
|
||||
maxRetries: 0
|
||||
});
|
||||
|
||||
return {
|
||||
title: object.title.trim().slice(0, 100),
|
||||
description: object.description.trim().slice(0, 300)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,22 +1,51 @@
|
||||
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';
|
||||
import {
|
||||
aggregateTokenUsageByUser,
|
||||
countRequests,
|
||||
getDailyTokenUsageHistory,
|
||||
getMonthlyTokenUsage,
|
||||
getOrgCreditBalance,
|
||||
insertTokenUsageAndDrainCredits,
|
||||
summarizePurchases,
|
||||
upsertCreditBalance
|
||||
} from '@cio/db/queries/agent';
|
||||
import type { UsageLeaderboardRow } from '@cio/db/queries/agent';
|
||||
import { getActiveOrganizationPlan } from '@cio/db/queries/organization';
|
||||
|
||||
import { AppError } from '@api/utils/errors';
|
||||
|
||||
function startOfCurrentMonth(): Date {
|
||||
const date = new Date();
|
||||
date.setDate(1);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
|
||||
return date;
|
||||
}
|
||||
|
||||
// ─── Plan-Based Token Allowances ─────────────────────────────────────────────
|
||||
|
||||
const PLAN_TOKEN_ALLOWANCES: Record<string, number> = {
|
||||
BASIC: 50_000,
|
||||
EARLY_ADOPTER: 500_000,
|
||||
ENTERPRISE: 2_000_000
|
||||
BASIC: 500_000,
|
||||
EARLY_ADOPTER: 3_000_000,
|
||||
ENTERPRISE: 15_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.
|
||||
*/
|
||||
// ─── Model Cost Multipliers ───────────────────────────────────────────────────
|
||||
|
||||
// Blended multiplier vs Gemini 2.5 Flash baseline ($0.15/1M); ~80/20 input/output mix; unknown → 1×.
|
||||
const MODEL_COST_MULTIPLIER: Record<string, number> = {
|
||||
'gemini-2.5-flash': 1,
|
||||
'gpt-5.4-mini': 3,
|
||||
'claude-sonnet-4-6': 11,
|
||||
'claude-haiku-4-5-20251001': 1,
|
||||
'kimi-k2.6': 3
|
||||
};
|
||||
|
||||
function computeCostUnits(promptTokens: number, completionTokens: number, model: string): number {
|
||||
const multiplier = MODEL_COST_MULTIPLIER[model] ?? 1;
|
||||
return Math.round((promptTokens + completionTokens) * multiplier);
|
||||
}
|
||||
|
||||
async function getPlanAllowance(orgId: string): Promise<{ planName: string; allowance: number }> {
|
||||
const activePlan = await getActiveOrganizationPlan(orgId);
|
||||
|
||||
@@ -32,42 +61,16 @@ async function getPlanAllowance(orgId: string): Promise<{ planName: string; allo
|
||||
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);
|
||||
export async function getMonthlyUsage(orgId: string): Promise<number> {
|
||||
return getMonthlyTokenUsage(orgId, startOfCurrentMonth());
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
export async function getCreditBalance(orgId: string): Promise<number> {
|
||||
return getOrgCreditBalance(orgId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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([
|
||||
const [{ allowance }, monthlyUsage, creditBalance] = await Promise.all([
|
||||
getPlanAllowance(orgId),
|
||||
getMonthlyUsage(orgId),
|
||||
getCreditBalance(orgId)
|
||||
@@ -84,9 +87,6 @@ export async function getTokenBalance(orgId: string): Promise<TokenBalance> {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the org has sufficient tokens. Throws 402 if exhausted.
|
||||
*/
|
||||
export async function enforceTokenBalance(orgId: string): Promise<TokenBalance> {
|
||||
const balance = await getTokenBalance(orgId);
|
||||
|
||||
@@ -97,9 +97,17 @@ export async function enforceTokenBalance(orgId: string): Promise<TokenBalance>
|
||||
return balance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record token usage after an LLM call.
|
||||
*/
|
||||
export function computePurchasedTokenOverflow(params: {
|
||||
allowance: number;
|
||||
monthlyUsageBefore: number;
|
||||
requestTokens: number;
|
||||
}): number {
|
||||
const allowanceRemainingBefore = Math.max(0, params.allowance - params.monthlyUsageBefore);
|
||||
|
||||
return Math.max(0, params.requestTokens - allowanceRemainingBefore);
|
||||
}
|
||||
|
||||
/** Record token usage after an LLM call; drains purchased credits when usage exceeds plan allowance. */
|
||||
export async function recordTokenUsage(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
@@ -107,29 +115,28 @@ export async function recordTokenUsage(
|
||||
usage: TokenUsage,
|
||||
model: string
|
||||
): Promise<void> {
|
||||
await db.insert(schema.aiTokenUsage).values({
|
||||
const costUnits = computeCostUnits(usage.promptTokens, usage.completionTokens, model);
|
||||
const { allowance } = await getPlanAllowance(orgId);
|
||||
|
||||
await insertTokenUsageAndDrainCredits({
|
||||
orgId,
|
||||
userId,
|
||||
courseId,
|
||||
promptTokens: usage.promptTokens,
|
||||
completionTokens: usage.completionTokens,
|
||||
model
|
||||
costUnits,
|
||||
model,
|
||||
planAllowance: allowance,
|
||||
since: startOfCurrentMonth()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
@@ -143,67 +150,76 @@ export interface DailyUsage {
|
||||
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)
|
||||
}));
|
||||
return getDailyTokenUsageHistory(orgId, startOfCurrentMonth());
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)]);
|
||||
export interface PurchasedSummary {
|
||||
totalPurchasedTokens: number;
|
||||
totalSpentCents: number;
|
||||
currency: 'USD';
|
||||
currentBalance: number;
|
||||
lastPurchaseAt: string | null;
|
||||
}
|
||||
|
||||
return { ...balance, history };
|
||||
export async function getPurchasedSummary(orgId: string): Promise<PurchasedSummary> {
|
||||
const [summary, currentBalance] = await Promise.all([summarizePurchases(orgId), getCreditBalance(orgId)]);
|
||||
|
||||
return {
|
||||
totalPurchasedTokens: summary.totalPurchasedTokens,
|
||||
totalSpentCents: summary.totalSpentCents,
|
||||
currency: 'USD',
|
||||
currentBalance,
|
||||
lastPurchaseAt: summary.lastPurchaseAt
|
||||
};
|
||||
}
|
||||
|
||||
export interface LeaderboardEntry {
|
||||
userId: string;
|
||||
fullname: string | null;
|
||||
email: string | null;
|
||||
avatarUrl: string | null;
|
||||
tokens: number;
|
||||
requests: number;
|
||||
favoriteModel: string | null;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export async function getTeamLeaderboard(orgId: string): Promise<{ entries: LeaderboardEntry[]; totalTokens: number }> {
|
||||
const rows: UsageLeaderboardRow[] = await aggregateTokenUsageByUser(orgId, startOfCurrentMonth());
|
||||
const totalTokens = rows.reduce((sum: number, row: UsageLeaderboardRow) => sum + row.tokens, 0);
|
||||
|
||||
const entries: LeaderboardEntry[] = rows.map((row: UsageLeaderboardRow) => ({
|
||||
userId: row.userId,
|
||||
fullname: row.fullname,
|
||||
email: row.email,
|
||||
avatarUrl: row.avatarUrl,
|
||||
tokens: row.tokens,
|
||||
requests: row.requests,
|
||||
favoriteModel: row.favoriteModel,
|
||||
percentage: totalTokens > 0 ? row.tokens / totalTokens : 0
|
||||
}));
|
||||
|
||||
return { entries, totalTokens };
|
||||
}
|
||||
|
||||
export async function getDetailedUsage(orgId: string) {
|
||||
const start = startOfCurrentMonth();
|
||||
const [balance, history, requestsThisMonth] = await Promise.all([
|
||||
getTokenBalance(orgId),
|
||||
getDailyTokenUsageHistory(orgId, start),
|
||||
countRequests(orgId, start)
|
||||
]);
|
||||
|
||||
return { ...balance, history, requestsThisMonth };
|
||||
}
|
||||
|
||||
// ─── 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;
|
||||
return upsertCreditBalance(orgId, amount);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
getCourseImportDraftByIdempotencyKey,
|
||||
updateCourseImportDraft
|
||||
} from '@cio/db/queries/course-import';
|
||||
import { getCourseById } from '@cio/db/queries/course';
|
||||
import { getCourseById, isCourseSlugTaken, updateCourseSlug } from '@cio/db/queries/course';
|
||||
import { getLessonLanguagesByLessonIds } from '@cio/db/queries/lesson';
|
||||
import { getOrganizationById } from '@cio/db/queries/organization';
|
||||
import { getCourseTagsForOrganization } from '@cio/db/queries/tag';
|
||||
@@ -45,10 +45,7 @@ import {
|
||||
} from '@cio/utils/validation/course-import';
|
||||
import { getCourseOrganizationId } from '@cio/db/queries/tag';
|
||||
import { assignCourseTagsByName } from '@api/services/tag';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import * as schema from '@cio/db/schema';
|
||||
import { generateSlug } from '@cio/utils/functions';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
type DraftSummary = {
|
||||
sectionCount: number;
|
||||
@@ -104,13 +101,8 @@ async function generateUniqueCourseSlug(baseSlug: string): Promise<string> {
|
||||
let candidate = baseSlug;
|
||||
|
||||
while (true) {
|
||||
const existing = await db
|
||||
.select({ id: schema.course.id })
|
||||
.from(schema.course)
|
||||
.where(eq(schema.course.slug, candidate))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
const taken = await isCourseSlugTaken(candidate);
|
||||
if (!taken) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
@@ -127,8 +119,13 @@ async function ensureCourseSlug(courseId: string, title: string | null | undefin
|
||||
|
||||
const baseSlug = generateSlug(title, { fallback: 'course' });
|
||||
const uniqueSlug = await generateUniqueCourseSlug(baseSlug);
|
||||
await updateCourse(courseId, { slug: uniqueSlug });
|
||||
return uniqueSlug;
|
||||
const updated = await updateCourseSlug(courseId, uniqueSlug);
|
||||
|
||||
if (!updated) {
|
||||
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return updated.slug ?? uniqueSlug;
|
||||
}
|
||||
|
||||
function buildCourseBaseUrl(organization: TOrganization) {
|
||||
|
||||
@@ -9,13 +9,17 @@ import {
|
||||
createLessons,
|
||||
createOptions,
|
||||
createQuestions,
|
||||
syncOptionIdSequence,
|
||||
getCourseById,
|
||||
getExercisesByLessonIds,
|
||||
getLessonLanguagesByLessonIds,
|
||||
getLessonsByCourseId,
|
||||
getOptionsByQuestionIds,
|
||||
getQuestionsByExerciseIds,
|
||||
getCourseSectionsByCourseId
|
||||
getCourseSectionsByCourseId,
|
||||
getExerciseSectionsByExerciseIds,
|
||||
createExerciseSections,
|
||||
updateExerciseSection
|
||||
} from '@db/queries';
|
||||
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
@@ -72,7 +76,8 @@ async function cloneExercises(newLessons: TLesson[], oldLessons: TLesson[]): Pro
|
||||
title: exercise.title,
|
||||
description: exercise.description,
|
||||
dueBy: new Date().toISOString(),
|
||||
lessonId: lessonIdMap.get(exercise.lessonId!)!
|
||||
lessonId: lessonIdMap.get(exercise.lessonId!)!,
|
||||
sectionDisplayMode: exercise.sectionDisplayMode
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -84,6 +89,47 @@ async function cloneExercises(newLessons: TLesson[], oldLessons: TLesson[]): Pro
|
||||
|
||||
// 6. Fetch all questions for old exercises
|
||||
const oldExerciseIds = oldExercises.map((ex) => ex.id);
|
||||
const oldSections = await getExerciseSectionsByExerciseIds(oldExerciseIds);
|
||||
const exerciseSectionIdMap = new Map<string, string>();
|
||||
|
||||
if (oldSections.length > 0) {
|
||||
const newSections = await createExerciseSections(
|
||||
oldSections.map((section) => ({
|
||||
title: section.title,
|
||||
description: section.description,
|
||||
order: section.order,
|
||||
colorTheme: section.colorTheme,
|
||||
afterBehavior: section.afterBehavior,
|
||||
exerciseId: exerciseIdMap.get(section.exerciseId)!
|
||||
}))
|
||||
);
|
||||
|
||||
oldSections.forEach((oldSection, index) => {
|
||||
const newSection = newSections[index];
|
||||
if (newSection) {
|
||||
exerciseSectionIdMap.set(oldSection.id, newSection.id);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
newSections.map((newSection) => {
|
||||
const behavior = newSection.afterBehavior as { action?: string; exerciseSectionId?: string };
|
||||
if (behavior.action !== 'go_to_section' || !behavior.exerciseSectionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const remappedId = exerciseSectionIdMap.get(behavior.exerciseSectionId);
|
||||
if (!remappedId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return updateExerciseSection(newSection.id, {
|
||||
afterBehavior: { action: 'go_to_section', exerciseSectionId: remappedId }
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const oldQuestions = await getQuestionsByExerciseIds(oldExerciseIds);
|
||||
|
||||
if (!oldQuestions || oldQuestions.length === 0) {
|
||||
@@ -99,6 +145,9 @@ async function cloneExercises(newLessons: TLesson[], oldLessons: TLesson[]): Pro
|
||||
order: question.order,
|
||||
questionTypeId: question.questionTypeId,
|
||||
settings: question.settings,
|
||||
exerciseSectionId: question.exerciseSectionId
|
||||
? (exerciseSectionIdMap.get(question.exerciseSectionId) ?? null)
|
||||
: null,
|
||||
exerciseId: exerciseIdMap.get(question.exerciseId)!
|
||||
}))
|
||||
);
|
||||
@@ -130,6 +179,7 @@ async function cloneExercises(newLessons: TLesson[], oldLessons: TLesson[]): Pro
|
||||
}
|
||||
|
||||
// 10. Insert options with new question IDs
|
||||
await syncOptionIdSequence();
|
||||
await createOptions(
|
||||
oldOptions
|
||||
.map((option) => {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import * as schema from '@cio/db/schema';
|
||||
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import {
|
||||
calcCourseProgressPercent,
|
||||
@@ -12,14 +10,12 @@ import {
|
||||
getStudentSubmissionsForExercise
|
||||
} from '@cio/db/queries/course/certification-exercise';
|
||||
import {
|
||||
getCourseById,
|
||||
getCourseCertificationRow,
|
||||
getCourseProgress as getCourseProgressQuery,
|
||||
type TCourseCertificationRow
|
||||
} from '@cio/db/queries/course/course';
|
||||
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export type CertificationBlocker = {
|
||||
code:
|
||||
@@ -255,15 +251,9 @@ export async function evaluateCourseCertification(
|
||||
export async function assertCertificateDownloadAllowed(courseId: string, profileId: string): Promise<void> {
|
||||
const evaluation = await evaluateCourseCertification(courseId, profileId);
|
||||
|
||||
const [courseMeta] = await db
|
||||
.select({
|
||||
certificate: schema.course.certificate
|
||||
})
|
||||
.from(schema.course)
|
||||
.where(eq(schema.course.id, courseId))
|
||||
.limit(1);
|
||||
const [courseRow] = await getCourseById(courseId);
|
||||
|
||||
if (!courseMeta?.certificate?.isDownloadable) {
|
||||
if (!courseRow?.certificate?.isDownloadable) {
|
||||
throw new AppError('Certificate download is not enabled for this course', ErrorCodes.VALIDATION_ERROR, 403);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
||||
import { ContentType } from '@cio/utils/constants';
|
||||
import { db, type DbOrTxClient } from '@cio/db/drizzle';
|
||||
import { sql, inArray } from 'drizzle-orm';
|
||||
import { courseSection, lesson, exercise } from '@cio/db/schema';
|
||||
import type { DbOrTxClient } from '@cio/db/drizzle';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import { applyCourseContentBulkUpdates, applyCourseSectionOrderUpdates } from '@cio/db/queries/course/content-batch';
|
||||
import { deleteCourseSection, getCourseSectionById, getCourseSectionsByCourseId } from '@cio/db/queries/course';
|
||||
import { OperationalQueryError } from '@cio/db/queries/query-errors';
|
||||
import { getCourseContentItems, type CourseContentItemRow } from '@cio/db/queries/course/content';
|
||||
import { deleteLesson } from '@cio/db/queries/lesson';
|
||||
import { deleteCourseSection, getCourseSectionById, getCourseSectionsByCourseId } from '@cio/db/queries/course';
|
||||
import { deleteExercise } from '@cio/db/queries/exercise/exercise';
|
||||
import type { TCourseContentDelete, TCourseContentReorder, TCourseContentUpdate } from '@cio/utils/validation/course';
|
||||
|
||||
@@ -17,24 +19,6 @@ type CourseContentReorderResult = {
|
||||
updatedExercises: number;
|
||||
};
|
||||
|
||||
function buildUpdatePayload(item: TCourseContentUpdate['items'][number]) {
|
||||
const payload: Record<string, unknown> = {};
|
||||
|
||||
if (item.isUnlocked !== undefined) {
|
||||
payload.isUnlocked = item.isUnlocked;
|
||||
}
|
||||
|
||||
if (item.order !== undefined) {
|
||||
payload.order = item.order;
|
||||
}
|
||||
|
||||
if (item.sectionId !== undefined) {
|
||||
payload.sectionId = item.sectionId;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function buildContentKey(item: { id: string; type: string }) {
|
||||
return `${item.type}-${item.id}`;
|
||||
}
|
||||
@@ -103,165 +87,37 @@ async function assertCourseSections(courseId: string, sections: NonNullable<TCou
|
||||
});
|
||||
}
|
||||
|
||||
async function applyCourseContentUpdates(tx: DbOrTxClient, items: TCourseContentUpdate['items']) {
|
||||
const updates = items
|
||||
.map((item) => ({
|
||||
item,
|
||||
payload: buildUpdatePayload(item)
|
||||
}))
|
||||
.filter(({ payload }) => Object.keys(payload).length > 0);
|
||||
|
||||
if (updates.length === 0) {
|
||||
return {
|
||||
updatedItems: 0,
|
||||
updatedLessons: 0,
|
||||
updatedExercises: 0
|
||||
};
|
||||
function mapContentBatchError(error: unknown): AppError | null {
|
||||
if (error instanceof OperationalQueryError) {
|
||||
return new AppError(error.message, error.errorCode, error.statusCode as ContentfulStatusCode, error.field);
|
||||
}
|
||||
|
||||
const updatedAt = new Date().toISOString();
|
||||
|
||||
const lessonUpdates = updates.filter(({ item }) => item.type === ContentType.Lesson);
|
||||
const exerciseUpdates = updates.filter(({ item }) => item.type === ContentType.Exercise);
|
||||
|
||||
if (lessonUpdates.length > 0) {
|
||||
const lessonIds = lessonUpdates.map(({ item }) => item.id);
|
||||
const lessonOrderUpdates = lessonUpdates
|
||||
.filter(({ payload }) => payload.order !== undefined)
|
||||
.map(({ item, payload }) => ({ id: item.id, value: payload.order }));
|
||||
const lessonSectionUpdates = lessonUpdates
|
||||
.filter(({ payload }) => payload.sectionId !== undefined)
|
||||
.map(({ item, payload }) => ({ id: item.id, value: payload.sectionId }));
|
||||
const lessonUnlockUpdates = lessonUpdates
|
||||
.filter(({ payload }) => payload.isUnlocked !== undefined)
|
||||
.map(({ item, payload }) => ({ id: item.id, value: payload.isUnlocked }));
|
||||
|
||||
const lessonSet: Record<string, unknown> = {
|
||||
updatedAt
|
||||
};
|
||||
|
||||
if (lessonOrderUpdates.length > 0) {
|
||||
lessonSet.order = sql`case ${lesson.id} ${sql.join(
|
||||
lessonOrderUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
|
||||
sql` `
|
||||
)} else ${lesson.order} end`;
|
||||
}
|
||||
|
||||
if (lessonSectionUpdates.length > 0) {
|
||||
lessonSet.sectionId = sql`case ${lesson.id} ${sql.join(
|
||||
lessonSectionUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
|
||||
sql` `
|
||||
)} else ${lesson.sectionId} end`;
|
||||
}
|
||||
|
||||
if (lessonUnlockUpdates.length > 0) {
|
||||
lessonSet.isUnlocked = sql`case ${lesson.id} ${sql.join(
|
||||
lessonUnlockUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
|
||||
sql` `
|
||||
)} else ${lesson.isUnlocked} end`;
|
||||
}
|
||||
|
||||
const updatedLessons = await tx
|
||||
.update(lesson)
|
||||
.set(lessonSet)
|
||||
.where(inArray(lesson.id, lessonIds))
|
||||
.returning({ id: lesson.id });
|
||||
|
||||
if (updatedLessons.length !== lessonIds.length) {
|
||||
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
|
||||
}
|
||||
}
|
||||
|
||||
if (exerciseUpdates.length > 0) {
|
||||
const exerciseIds = exerciseUpdates.map(({ item }) => item.id);
|
||||
const exerciseOrderUpdates = exerciseUpdates
|
||||
.filter(({ payload }) => payload.order !== undefined)
|
||||
.map(({ item, payload }) => ({ id: item.id, value: payload.order }));
|
||||
const exerciseSectionUpdates = exerciseUpdates
|
||||
.filter(({ payload }) => payload.sectionId !== undefined)
|
||||
.map(({ item, payload }) => ({ id: item.id, value: payload.sectionId }));
|
||||
const exerciseUnlockUpdates = exerciseUpdates
|
||||
.filter(({ payload }) => payload.isUnlocked !== undefined)
|
||||
.map(({ item, payload }) => ({ id: item.id, value: payload.isUnlocked }));
|
||||
|
||||
const exerciseSet: Record<string, unknown> = {
|
||||
updatedAt
|
||||
};
|
||||
|
||||
if (exerciseOrderUpdates.length > 0) {
|
||||
exerciseSet.order = sql`case ${exercise.id} ${sql.join(
|
||||
exerciseOrderUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
|
||||
sql` `
|
||||
)} else ${exercise.order} end`;
|
||||
}
|
||||
|
||||
if (exerciseSectionUpdates.length > 0) {
|
||||
exerciseSet.sectionId = sql`case ${exercise.id} ${sql.join(
|
||||
exerciseSectionUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
|
||||
sql` `
|
||||
)} else ${exercise.sectionId} end`;
|
||||
}
|
||||
|
||||
if (exerciseUnlockUpdates.length > 0) {
|
||||
exerciseSet.isUnlocked = sql`case ${exercise.id} ${sql.join(
|
||||
exerciseUnlockUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
|
||||
sql` `
|
||||
)} else ${exercise.isUnlocked} end`;
|
||||
}
|
||||
|
||||
const updatedExercises = await tx
|
||||
.update(exercise)
|
||||
.set(exerciseSet)
|
||||
.where(inArray(exercise.id, exerciseIds))
|
||||
.returning({ id: exercise.id });
|
||||
|
||||
if (updatedExercises.length !== exerciseIds.length) {
|
||||
throw new AppError('Exercise not found', ErrorCodes.EXERCISE_NOT_FOUND, 404);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
updatedItems: updates.length,
|
||||
updatedLessons: lessonUpdates.length,
|
||||
updatedExercises: exerciseUpdates.length
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
async function applySectionOrderUpdates(tx: DbOrTxClient, sections: NonNullable<TCourseContentReorder['sections']>) {
|
||||
if (sections.length === 0) {
|
||||
return 0;
|
||||
async function transactionWithContentBatch<TResult>(runner: (tx: DbOrTxClient) => Promise<TResult>): Promise<TResult> {
|
||||
try {
|
||||
return await db.transaction(async (tx) => runner(tx));
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const mapped = mapContentBatchError(error);
|
||||
|
||||
if (mapped) {
|
||||
throw mapped;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const sectionIds = sections.map((section) => section.id);
|
||||
const updatedAt = new Date().toISOString();
|
||||
const sectionSet: Record<string, unknown> = {
|
||||
updatedAt,
|
||||
order: sql`case ${courseSection.id} ${sql.join(
|
||||
sections.map((section) => sql`when ${section.id} then ${section.order}`),
|
||||
sql` `
|
||||
)} else ${courseSection.order} end`
|
||||
};
|
||||
|
||||
const updatedSections = await tx
|
||||
.update(courseSection)
|
||||
.set(sectionSet)
|
||||
.where(inArray(courseSection.id, sectionIds))
|
||||
.returning({ id: courseSection.id });
|
||||
|
||||
if (updatedSections.length !== sectionIds.length) {
|
||||
throw new AppError('Course section not found', ErrorCodes.COURSE_SECTION_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return sections.length;
|
||||
}
|
||||
|
||||
export async function updateCourseContent(courseId: string, items: TCourseContentUpdate['items']) {
|
||||
try {
|
||||
await assertCourseContentItems(courseId, items);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await applyCourseContentUpdates(tx, items);
|
||||
});
|
||||
await transactionWithContentBatch(async (tx) => applyCourseContentBulkUpdates(tx, items));
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
@@ -288,10 +144,11 @@ export async function reorderCourseContent(
|
||||
await assertCourseContentItems(courseId, payload.items);
|
||||
}
|
||||
|
||||
return await db.transaction(async (tx) => {
|
||||
const updatedSections = payload.sections?.length ? await applySectionOrderUpdates(tx, payload.sections) : 0;
|
||||
return await transactionWithContentBatch(async (tx) => {
|
||||
const updatedSections = payload.sections?.length ? await applyCourseSectionOrderUpdates(tx, payload.sections) : 0;
|
||||
|
||||
const itemResult = payload.items?.length
|
||||
? await applyCourseContentUpdates(tx, payload.items)
|
||||
? await applyCourseContentBulkUpdates(tx, payload.items)
|
||||
: {
|
||||
updatedItems: 0,
|
||||
updatedLessons: 0,
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { sanitizeHtml, sanitizeOptionalHtml, sanitizeUnknownStrings } from '@api/utils/sanitize-html';
|
||||
import { addGroupMember, createGroup, getCourseProgramAccess } from '@cio/db/queries/group';
|
||||
import {
|
||||
createCourseNewsfeed,
|
||||
createCourse as createCourseQuery,
|
||||
createCourseSections,
|
||||
deleteCourse as deleteCourseQuery,
|
||||
getCourseProgress as getCourseProgressQuery,
|
||||
getCourseTypeById,
|
||||
getCourseWithRelations,
|
||||
updateCourse as updateCourseQuery,
|
||||
updateLessonsSectionId
|
||||
} from '@cio/db/queries/course';
|
||||
import {
|
||||
addGroupMember,
|
||||
createGroup,
|
||||
getCourseProgramAccess,
|
||||
insertGroupMembersOnConflictDoNothing
|
||||
} from '@cio/db/queries/group';
|
||||
import {
|
||||
getLastLogin,
|
||||
getLessonsWithCompletion,
|
||||
@@ -22,11 +28,12 @@ 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';
|
||||
import { updateExercisesSectionId } from '@cio/db/queries/exercise/exercise';
|
||||
import { getProfileById } from '@cio/db/queries/auth';
|
||||
import { insertOrganizationMembersOnConflictDoNothing } from '@cio/db/queries/organization';
|
||||
import { buildCourseContent, calcPercentageWithRounding, formatLastSeen, type CourseContent } from './utils';
|
||||
import { guardCourseTypeTransition } from './public-course-guard';
|
||||
|
||||
const DEFAULT_CONTENT_GROUPING = true;
|
||||
const DEFAULT_SECTION_TITLE = 'First Section [edit me]';
|
||||
@@ -45,26 +52,30 @@ export async function ensureProgramCourseAccess(courseId: string, profileId: str
|
||||
const normalizedEmail = access.profileEmail?.trim() || undefined;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.insert(schema.organizationmember)
|
||||
.values({
|
||||
organizationId,
|
||||
roleId: access.roleId,
|
||||
profileId,
|
||||
email: normalizedEmail,
|
||||
verified: true
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
await insertOrganizationMembersOnConflictDoNothing(
|
||||
[
|
||||
{
|
||||
organizationId,
|
||||
roleId: access.roleId,
|
||||
profileId,
|
||||
email: normalizedEmail,
|
||||
verified: true
|
||||
}
|
||||
],
|
||||
tx
|
||||
);
|
||||
|
||||
await tx
|
||||
.insert(schema.groupmember)
|
||||
.values({
|
||||
groupId: access.courseGroupId,
|
||||
roleId: access.roleId,
|
||||
profileId,
|
||||
email: normalizedEmail
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
await insertGroupMembersOnConflictDoNothing(
|
||||
[
|
||||
{
|
||||
groupId: access.courseGroupId,
|
||||
roleId: access.roleId,
|
||||
profileId,
|
||||
email: normalizedEmail ?? undefined
|
||||
}
|
||||
],
|
||||
tx
|
||||
);
|
||||
});
|
||||
|
||||
return true;
|
||||
@@ -266,6 +277,16 @@ export async function updateCourse(courseId: string, data: Partial<TCourse>) {
|
||||
slug: data.slug
|
||||
};
|
||||
|
||||
if (data.type !== undefined) {
|
||||
const currentType = await getCourseTypeById(courseId);
|
||||
|
||||
await guardCourseTypeTransition({
|
||||
courseId,
|
||||
currentType: currentType ?? null,
|
||||
nextType: data.type
|
||||
});
|
||||
}
|
||||
|
||||
if (data.certificate?.requiredExerciseId) {
|
||||
const ok = await exerciseBelongsToCourse(data.certificate.requiredExerciseId, courseId);
|
||||
if (!ok) {
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import * as schema from '@cio/db/schema';
|
||||
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import {
|
||||
countInviteDistinctPreviewIps,
|
||||
createCourseInvite,
|
||||
@@ -11,14 +7,25 @@ import {
|
||||
listCourseInviteAudit,
|
||||
listCourseInviteAuditStats,
|
||||
listCourseInvites,
|
||||
revokeCourseInvite
|
||||
optimisticIncrementCourseInviteUsedCount,
|
||||
revokeCourseInvite,
|
||||
selectCourseInviteAcceptBundleByTokenHash
|
||||
} from '@cio/db/queries/course/invite';
|
||||
import { getCourseById, getCourseWithOrgData, getCourseWithRelations } from '@cio/db/queries/course';
|
||||
import { createOrganizationMember, getOrganizationMemberIdByOrgAndProfile } from '@cio/db/queries/organization';
|
||||
import { addGroupMember, getGroupMemberIdByGroupAndProfile } from '@cio/db/queries/group';
|
||||
import {
|
||||
getCourseById,
|
||||
getCourseWithOrgData,
|
||||
getCourseWithRelations,
|
||||
isCourseSlugTaken,
|
||||
updateCourseSlug
|
||||
} from '@cio/db/queries/course';
|
||||
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import type { TCreateCourseInvite } from '@cio/utils/validation/course/invite';
|
||||
import type { TNewCourseInviteAudit } from '@db/types';
|
||||
import crypto from 'node:crypto';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
||||
import { getCourseTeachers } from '@cio/db/queries/course/people';
|
||||
import { getProfileById } from '@cio/db/queries/auth';
|
||||
@@ -26,6 +33,7 @@ import { buildEmailFromName, sendEmail } from '@cio/email';
|
||||
import { getProfileByEmail, markUserAndProfileEmailVerified } from '@cio/db/queries/auth';
|
||||
import { generateSlug } from '@cio/utils/functions';
|
||||
import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
|
||||
type InviteStatus = 'ACTIVE' | 'EXPIRED' | 'USED_UP' | 'REVOKED';
|
||||
type InvitePreset = 'ONE_TIME_24H' | 'MULTI_USE_7D' | 'MULTI_USE_30D' | 'CUSTOM';
|
||||
@@ -46,13 +54,9 @@ async function generateUniqueCourseSlug(baseSlug: string): Promise<string> {
|
||||
let candidate = baseSlug;
|
||||
|
||||
while (true) {
|
||||
const existing = await db
|
||||
.select({ id: schema.course.id })
|
||||
.from(schema.course)
|
||||
.where(eq(schema.course.slug, candidate))
|
||||
.limit(1);
|
||||
const taken = await isCourseSlugTaken(candidate);
|
||||
|
||||
if (existing.length === 0) {
|
||||
if (!taken) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
@@ -65,11 +69,7 @@ async function ensureCourseSlug(courseId: string, title: string | null | undefin
|
||||
const baseSlug = generateSlug(title, { fallback: 'course' });
|
||||
const uniqueSlug = await generateUniqueCourseSlug(baseSlug);
|
||||
|
||||
const [updated] = await db
|
||||
.update(schema.course)
|
||||
.set({ slug: uniqueSlug, updatedAt: new Date().toISOString() })
|
||||
.where(eq(schema.course.id, courseId))
|
||||
.returning({ slug: schema.course.slug });
|
||||
const updated = await updateCourseSlug(courseId, uniqueSlug);
|
||||
|
||||
return updated?.slug ?? uniqueSlug;
|
||||
}
|
||||
@@ -276,7 +276,7 @@ function toInviteResponse(
|
||||
async function recordInviteAudit(
|
||||
inviteId: string,
|
||||
courseId: string,
|
||||
eventType: (typeof schema.courseInviteEventType.enumValues)[number],
|
||||
eventType: TNewCourseInviteAudit['eventType'],
|
||||
context: {
|
||||
actorProfileId?: string | null;
|
||||
targetEmail?: string | null;
|
||||
@@ -641,13 +641,9 @@ export async function enrollInCourse(
|
||||
|
||||
const normalizedEmail = user.email.toLowerCase().trim();
|
||||
|
||||
const [existingMember] = await db
|
||||
.select({ id: schema.groupmember.id })
|
||||
.from(schema.groupmember)
|
||||
.where(and(eq(schema.groupmember.groupId, groupId), eq(schema.groupmember.profileId, user.id)))
|
||||
.limit(1);
|
||||
const existingMemberId = await getGroupMemberIdByGroupAndProfile(groupId, user.id);
|
||||
|
||||
if (existingMember) {
|
||||
if (existingMemberId) {
|
||||
await ensureComplianceEnrollmentRecordsForProfiles([courseId], [user.id]);
|
||||
|
||||
return {
|
||||
@@ -657,14 +653,10 @@ export async function enrollInCourse(
|
||||
};
|
||||
}
|
||||
|
||||
const [orgMember] = await db
|
||||
.select({ id: schema.organizationmember.id })
|
||||
.from(schema.organizationmember)
|
||||
.where(and(eq(schema.organizationmember.organizationId, org.id), eq(schema.organizationmember.profileId, user.id)))
|
||||
.limit(1);
|
||||
const orgMemberId = await getOrganizationMemberIdByOrgAndProfile(org.id, user.id);
|
||||
|
||||
if (!orgMember) {
|
||||
await db.insert(schema.organizationmember).values({
|
||||
if (!orgMemberId) {
|
||||
await createOrganizationMember({
|
||||
organizationId: org.id,
|
||||
roleId: ROLE.STUDENT,
|
||||
profileId: user.id,
|
||||
@@ -673,7 +665,7 @@ export async function enrollInCourse(
|
||||
});
|
||||
}
|
||||
|
||||
await db.insert(schema.groupmember).values({
|
||||
await addGroupMember({
|
||||
groupId,
|
||||
roleId: ROLE.STUDENT,
|
||||
profileId: user.id,
|
||||
@@ -901,29 +893,7 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
|
||||
const tokenHash = hashToken(token);
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const [inviteRow] = await tx
|
||||
.select({
|
||||
invite: schema.courseInvite,
|
||||
course: {
|
||||
id: schema.course.id,
|
||||
title: schema.course.title,
|
||||
status: schema.course.status,
|
||||
isPublished: schema.course.isPublished,
|
||||
metadata: schema.course.metadata,
|
||||
groupId: schema.group.id
|
||||
},
|
||||
organization: {
|
||||
id: schema.organization.id,
|
||||
name: schema.organization.name,
|
||||
siteName: schema.organization.siteName
|
||||
}
|
||||
})
|
||||
.from(schema.courseInvite)
|
||||
.innerJoin(schema.course, eq(schema.courseInvite.courseId, schema.course.id))
|
||||
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
|
||||
.innerJoin(schema.organization, eq(schema.group.organizationId, schema.organization.id))
|
||||
.where(eq(schema.courseInvite.tokenHash, tokenHash))
|
||||
.limit(1);
|
||||
const inviteRow = await selectCourseInviteAcceptBundleByTokenHash(tx, tokenHash);
|
||||
|
||||
if (!inviteRow) {
|
||||
throw new AppError('Invalid invite link', ErrorCodes.NOT_FOUND, 404);
|
||||
@@ -948,15 +918,9 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
|
||||
}
|
||||
|
||||
// Idempotent behavior: if user is already enrolled, don't consume invite again.
|
||||
const [existingMember] = await tx
|
||||
.select({
|
||||
id: schema.groupmember.id
|
||||
})
|
||||
.from(schema.groupmember)
|
||||
.where(and(eq(schema.groupmember.groupId, course.groupId), eq(schema.groupmember.profileId, user.id)))
|
||||
.limit(1);
|
||||
const existingMemberId = await getGroupMemberIdByGroupAndProfile(course.groupId, user.id, tx);
|
||||
|
||||
if (existingMember) {
|
||||
if (existingMemberId) {
|
||||
await markUserAndProfileEmailVerified(user.id, tx);
|
||||
|
||||
await recordInviteAudit(invite.id, invite.courseId, 'ACCEPTED', {
|
||||
@@ -1011,49 +975,34 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
|
||||
throw new AppError('This invite is restricted to specific email domains', ErrorCodes.UNAUTHORIZED, 403);
|
||||
}
|
||||
|
||||
const [orgMember] = await tx
|
||||
.select({
|
||||
id: schema.organizationmember.id
|
||||
})
|
||||
.from(schema.organizationmember)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.organizationmember.organizationId, organization.id),
|
||||
eq(schema.organizationmember.profileId, user.id)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
const orgMemberId = await getOrganizationMemberIdByOrgAndProfile(organization.id, user.id, tx);
|
||||
|
||||
if (!orgMember) {
|
||||
await tx.insert(schema.organizationmember).values({
|
||||
organizationId: organization.id,
|
||||
roleId: ROLE.STUDENT,
|
||||
profileId: user.id,
|
||||
email: normalizedEmail,
|
||||
verified: true
|
||||
});
|
||||
if (!orgMemberId) {
|
||||
await createOrganizationMember(
|
||||
{
|
||||
organizationId: organization.id,
|
||||
roleId: ROLE.STUDENT,
|
||||
profileId: user.id,
|
||||
email: normalizedEmail,
|
||||
verified: true
|
||||
},
|
||||
tx
|
||||
);
|
||||
}
|
||||
|
||||
await tx.insert(schema.groupmember).values({
|
||||
groupId: course.groupId,
|
||||
roleId: ROLE.STUDENT,
|
||||
profileId: user.id,
|
||||
email: normalizedEmail
|
||||
});
|
||||
await addGroupMember(
|
||||
{
|
||||
groupId: course.groupId,
|
||||
roleId: ROLE.STUDENT,
|
||||
profileId: user.id,
|
||||
email: normalizedEmail
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
await markUserAndProfileEmailVerified(user.id, tx);
|
||||
|
||||
const [consumeInvite] = await tx
|
||||
.update(schema.courseInvite)
|
||||
.set({
|
||||
usedCount: invite.usedCount + 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
lastUsedAt: new Date().toISOString()
|
||||
})
|
||||
.where(and(eq(schema.courseInvite.id, invite.id), eq(schema.courseInvite.usedCount, invite.usedCount)))
|
||||
.returning({
|
||||
id: schema.courseInvite.id
|
||||
});
|
||||
const consumeInvite = await optimisticIncrementCourseInviteUsedCount(tx, invite.id, invite.usedCount);
|
||||
|
||||
if (!consumeInvite) {
|
||||
throw new AppError('This invite was already used. Please try another link.', ErrorCodes.VALIDATION_ERROR, 409);
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import * as schema from '@cio/db/schema';
|
||||
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import type { TCourse, TOrganization } from '@db/types';
|
||||
import type { TCourseLandingPageMetadataUpdate, TCourseLandingPageUpdate } from '@cio/utils/validation/course';
|
||||
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import { env } from '@api/config/env';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { generateSlug } from '@cio/utils/functions';
|
||||
import { getCourseById } from '@cio/db/queries/course';
|
||||
import { getCourseById, isCourseSlugTaken, updateCourseSlug } from '@cio/db/queries/course';
|
||||
import { getCourseOrganizationId } from '@cio/db/queries/tag';
|
||||
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
||||
import { getOrganizationById } from '@cio/db/queries/organization';
|
||||
@@ -25,13 +21,8 @@ export async function generateUniqueCourseSlug(baseSlug: string): Promise<string
|
||||
let candidate = baseSlug;
|
||||
|
||||
while (true) {
|
||||
const existing = await db
|
||||
.select({ id: schema.course.id })
|
||||
.from(schema.course)
|
||||
.where(eq(schema.course.slug, candidate))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
const taken = await isCourseSlugTaken(candidate);
|
||||
if (!taken) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
@@ -48,8 +39,13 @@ export async function ensureCourseSlug(courseId: string, title: string | null |
|
||||
|
||||
const baseSlug = generateSlug(title, { fallback: 'course' });
|
||||
const uniqueSlug = await generateUniqueCourseSlug(baseSlug);
|
||||
await updateCourse(courseId, { slug: uniqueSlug });
|
||||
return uniqueSlug;
|
||||
const updated = await updateCourseSlug(courseId, uniqueSlug);
|
||||
|
||||
if (!updated) {
|
||||
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return updated.slug ?? uniqueSlug;
|
||||
}
|
||||
|
||||
export function buildCourseBaseUrl(
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { findNonAutoGradableQuestionsInCourse, getCourseTypeById } from '@cio/db/queries/course';
|
||||
import { AUTO_GRADABLE_QUESTION_TYPE_IDS, isAutoGradableQuestionTypeId } from '@cio/question-types';
|
||||
|
||||
/**
|
||||
* Throws `QUESTION_TYPE_NOT_ALLOWED_IN_PUBLIC_COURSE` when a non-auto-gradable question
|
||||
* type is being attached to an exercise that belongs to a `PUBLIC` course.
|
||||
*
|
||||
* Accepts the full list of question type ids being created / updated (ignore-null safe).
|
||||
*/
|
||||
export async function guardNonAutoGradableQuestionsForCourseType(params: {
|
||||
courseId: string;
|
||||
questionTypeIds: Array<number | null | undefined>;
|
||||
}): Promise<void> {
|
||||
const typeIds = params.questionTypeIds.filter((id): id is number => typeof id === 'number');
|
||||
if (typeIds.length === 0) return;
|
||||
|
||||
const hasDisallowed = typeIds.some((id) => !isAutoGradableQuestionTypeId(id));
|
||||
if (!hasDisallowed) return;
|
||||
|
||||
const rowType = await getCourseTypeById(params.courseId);
|
||||
|
||||
if (!rowType) return;
|
||||
|
||||
if (rowType === 'PUBLIC') {
|
||||
throw new AppError(
|
||||
'Public courses only support auto-gradable question types',
|
||||
ErrorCodes.QUESTION_TYPE_NOT_ALLOWED_IN_PUBLIC_COURSE,
|
||||
400,
|
||||
'questions'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When converting a course to `PUBLIC`, enumerate any questions whose type is not
|
||||
* auto-gradable. Throws `PUBLIC_COURSE_CONVERSION_BLOCKED` with the offending list
|
||||
* in `message`; callers should surface the list to the creator so they can fix them.
|
||||
*/
|
||||
export async function guardCourseTypeTransition(params: {
|
||||
courseId: string;
|
||||
currentType: string | null;
|
||||
nextType: string | null | undefined;
|
||||
}): Promise<void> {
|
||||
if (!params.nextType) return;
|
||||
if (params.nextType !== 'PUBLIC') return;
|
||||
if (params.currentType === 'PUBLIC') return;
|
||||
|
||||
const offenders = await findNonAutoGradableQuestionsInCourse(params.courseId, AUTO_GRADABLE_QUESTION_TYPE_IDS);
|
||||
if (offenders.length === 0) return;
|
||||
|
||||
const preview = offenders.slice(0, 5).map((item) => `"${item.questionTitle}" in "${item.exerciseTitle}"`);
|
||||
const more = offenders.length > 5 ? ` (+${offenders.length - 5} more)` : '';
|
||||
|
||||
throw new AppError(
|
||||
`Remove or replace these non-auto-gradable questions before converting to Public: ${preview.join('; ')}${more}`,
|
||||
ErrorCodes.PUBLIC_COURSE_CONVERSION_BLOCKED,
|
||||
400,
|
||||
'type'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import {
|
||||
getCourseRowBySlug,
|
||||
getPublicCourseItem as getPublicCourseItemQuery,
|
||||
getPublicCourseTreeBySlug,
|
||||
type PublicCourseItemContent,
|
||||
type PublicCourseTree
|
||||
} from '@cio/db/queries/course';
|
||||
import { getExerciseWithRelationsOptimized } from '@cio/db/queries/exercise';
|
||||
import { getExerciseSectionsByExerciseId } from '@cio/db/queries/exercise/exercise-section';
|
||||
import { fetchQuestionsAndOptions, transformQuestions } from '@api/services/exercise/utils';
|
||||
|
||||
/**
|
||||
* Anonymous-safe: fetch the full tree for a published public course by slug.
|
||||
* Callers should return 404 when this resolves to `null`.
|
||||
*/
|
||||
export async function getPublicCourseTreeService(courseSlug: string): Promise<PublicCourseTree | null> {
|
||||
try {
|
||||
return await getPublicCourseTreeBySlug(courseSlug);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError('Failed to load public course', ErrorCodes.COURSE_FETCH_FAILED, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export type PublicItemResponse =
|
||||
| (Extract<PublicCourseItemContent, { kind: 'lesson' }> & { kind: 'lesson' })
|
||||
| (Extract<PublicCourseItemContent, { kind: 'exercise' }> & {
|
||||
kind: 'exercise';
|
||||
questions: PublicExercisePayload;
|
||||
});
|
||||
|
||||
export interface PublicExercisePayload {
|
||||
unsectioned: PublicExerciseQuestion[];
|
||||
sections: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
order: number;
|
||||
colorTheme: string;
|
||||
afterBehavior: unknown;
|
||||
questions: PublicExerciseQuestion[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface PublicExerciseQuestion {
|
||||
id: number | string;
|
||||
title: string;
|
||||
questionTypeId: number;
|
||||
questionTypeKey: string;
|
||||
points: number;
|
||||
order: number | null;
|
||||
settings: Record<string, unknown>;
|
||||
options: Array<{
|
||||
id: number | string;
|
||||
label: string;
|
||||
/** Answer keys are intentionally shipped to the client for PUBLIC courses. */
|
||||
isCorrect: boolean;
|
||||
settings: Record<string, unknown>;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anonymous-safe: fetch a single lesson or exercise by its per-course slug.
|
||||
* For exercises, questions + options are returned with answer keys included
|
||||
* because PUBLIC courses grade entirely on the client.
|
||||
*/
|
||||
export async function getPublicCourseItemService(
|
||||
courseSlug: string,
|
||||
itemSlug: string
|
||||
): Promise<PublicItemResponse | null> {
|
||||
try {
|
||||
const baseItem = await getPublicCourseItemQuery(courseSlug, itemSlug);
|
||||
if (!baseItem) return null;
|
||||
|
||||
if (baseItem.kind === 'lesson') {
|
||||
return baseItem;
|
||||
}
|
||||
|
||||
const { exercise, questions } = await getExerciseWithRelationsOptimized(baseItem.id);
|
||||
const questionTypeMap = new Map(questions.map((question) => [question.questionType.id, question.questionType]));
|
||||
const allOptions = questions.flatMap((question) => question.options ?? []);
|
||||
const strippedQuestions = questions.map(({ questionType: _type, options: _options, ...rest }) => rest);
|
||||
const transformed = transformQuestions(strippedQuestions, allOptions, questionTypeMap);
|
||||
const sectionRows = await getExerciseSectionsByExerciseId(exercise.id);
|
||||
|
||||
const sectionMap = new Map(sectionRows.map((section) => [section.id, section]));
|
||||
const unsectioned: PublicExerciseQuestion[] = [];
|
||||
const sectionBuckets = new Map<string, PublicExerciseQuestion[]>();
|
||||
|
||||
for (const question of transformed) {
|
||||
// `transformQuestions` only keeps `{ id, label }` on `question.questionType`, so the DB
|
||||
// `typename` (e.g. RADIO) must come from the map built before stripping relations.
|
||||
const questionTypeRow = questionTypeMap.get(question.questionTypeId);
|
||||
const payloadQuestion: PublicExerciseQuestion = {
|
||||
id: question.id,
|
||||
title: question.title ?? '',
|
||||
questionTypeId: question.questionTypeId,
|
||||
questionTypeKey: questionTypeRow?.typename ?? '',
|
||||
points: question.points ?? 0,
|
||||
order: typeof question.order === 'number' ? question.order : null,
|
||||
settings: (question.settings ?? {}) as Record<string, unknown>,
|
||||
options: (question.options ?? []).map((option) => ({
|
||||
id: option.id,
|
||||
label: option.label ?? '',
|
||||
isCorrect: option.isCorrect ?? false,
|
||||
settings: (option.settings ?? {}) as Record<string, unknown>
|
||||
}))
|
||||
};
|
||||
|
||||
const sectionId = question.exerciseSectionId ?? null;
|
||||
if (sectionId && sectionMap.has(sectionId)) {
|
||||
const bucket = sectionBuckets.get(sectionId) ?? [];
|
||||
bucket.push(payloadQuestion);
|
||||
sectionBuckets.set(sectionId, bucket);
|
||||
} else {
|
||||
unsectioned.push(payloadQuestion);
|
||||
}
|
||||
}
|
||||
|
||||
const sections = sectionRows.map((section) => ({
|
||||
id: section.id,
|
||||
title: section.title,
|
||||
description: section.description,
|
||||
order: Number(section.order),
|
||||
colorTheme: section.colorTheme,
|
||||
afterBehavior: section.afterBehavior,
|
||||
questions: sectionBuckets.get(section.id) ?? []
|
||||
}));
|
||||
|
||||
return {
|
||||
...baseItem,
|
||||
questions: {
|
||||
unsectioned,
|
||||
sections
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
console.error('getPublicCourseItemService error:', error);
|
||||
throw new AppError('Failed to load public course item', ErrorCodes.COURSE_FETCH_FAILED, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a course slug to the backing course row with minimal fields. Used by
|
||||
* rate limiting / logging that need the course id without the full tree.
|
||||
*/
|
||||
export async function resolvePublicCourseBySlug(courseSlug: string) {
|
||||
return getCourseRowBySlug(courseSlug);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { getTakenItemSlugs } from '@cio/db/queries/course';
|
||||
import { SLUG_REGEX, resolveSlugCollision, slugifyTitle } from '@cio/utils/validation';
|
||||
|
||||
/**
|
||||
* Resolve an item (lesson or exercise) slug for a given course:
|
||||
* - If `requestedSlug` is provided, validate it and reject if already taken (excluding `ignoreItemId`).
|
||||
* - Otherwise derive a slug from `title` and append `-2`, `-3`, ... until free.
|
||||
*
|
||||
* Uniqueness is enforced across the union of `lesson.slug` and `exercise.slug` per course.
|
||||
*/
|
||||
export async function resolveItemSlug(params: {
|
||||
courseId: string;
|
||||
title: string;
|
||||
requestedSlug?: string;
|
||||
ignoreItemSlug?: string;
|
||||
}): Promise<string> {
|
||||
const { courseId, title, requestedSlug, ignoreItemSlug } = params;
|
||||
const takenSlugs = await getTakenItemSlugs(courseId);
|
||||
|
||||
if (ignoreItemSlug) {
|
||||
takenSlugs.delete(ignoreItemSlug);
|
||||
}
|
||||
|
||||
if (requestedSlug) {
|
||||
if (!SLUG_REGEX.test(requestedSlug)) {
|
||||
throw new AppError(
|
||||
'Slug must be lowercase letters, numbers, and single dashes',
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
400,
|
||||
'slug'
|
||||
);
|
||||
}
|
||||
if (takenSlugs.has(requestedSlug)) {
|
||||
throw new AppError('Slug is already in use in this course', ErrorCodes.SLUG_IN_USE, 409, 'slug');
|
||||
}
|
||||
return requestedSlug;
|
||||
}
|
||||
|
||||
const base = slugifyTitle(title);
|
||||
return resolveSlugCollision(base, takenSlugs);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { getLessonById } from '@cio/db/queries/lesson';
|
||||
import {
|
||||
createExercises,
|
||||
syncOptionIdSequence,
|
||||
syncQuestionIdSequence,
|
||||
createOptions,
|
||||
createQuestions,
|
||||
deleteExercise,
|
||||
@@ -14,14 +15,20 @@ import {
|
||||
getExerciseById,
|
||||
getExerciseCompletionByProfile,
|
||||
getExerciseCompletionsByProfile,
|
||||
getExerciseSectionsByExerciseId,
|
||||
getExerciseWithRelationsOptimized,
|
||||
getExercisesByCourseId,
|
||||
getLMSExercises,
|
||||
createExerciseSections,
|
||||
deleteExerciseSectionsByIds,
|
||||
updateExerciseSection,
|
||||
updateExercise,
|
||||
updateOptions,
|
||||
updateQuestions
|
||||
} from '@cio/db/queries/exercise';
|
||||
import { touchCourseUpdatedAt } from '@cio/db/queries/course';
|
||||
import { resolveItemSlug } from '@api/services/course/slug';
|
||||
import { guardNonAutoGradableQuestionsForCourseType } from '@api/services/course/public-course-guard';
|
||||
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import type { DbOrTxClient } from '@cio/db/drizzle';
|
||||
@@ -37,6 +44,17 @@ import {
|
||||
type ExerciseWithQuestions = TExercise & {
|
||||
isComplete: boolean;
|
||||
questions?: QuestionWithRelations[];
|
||||
sections?: ExerciseSectionWithQuestions[];
|
||||
};
|
||||
|
||||
type ExerciseSectionWithQuestions = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
order: number;
|
||||
colorTheme: string;
|
||||
afterBehavior: unknown;
|
||||
questions: QuestionWithRelations[];
|
||||
};
|
||||
|
||||
type ExerciseWithCompletion = TExercise & {
|
||||
@@ -62,10 +80,126 @@ function sanitizeExercisePayload(data: TExerciseCreate | TExerciseUpdate): TExer
|
||||
return {
|
||||
...data,
|
||||
description: sanitizeOptionalHtml(data.description),
|
||||
sections:
|
||||
'sections' in data
|
||||
? data.sections?.map((section) => ({
|
||||
...section,
|
||||
title: sanitizeHtml(section.title),
|
||||
description: sanitizeOptionalHtml(section.description ?? undefined)
|
||||
}))
|
||||
: undefined,
|
||||
questions: sanitizeExerciseQuestions(data.questions)
|
||||
};
|
||||
}
|
||||
|
||||
function groupQuestionsBySections(
|
||||
sections: Awaited<ReturnType<typeof getExerciseSectionsByExerciseId>>,
|
||||
questions: QuestionWithRelations[]
|
||||
) {
|
||||
const questionsBySectionId = new Map<string, QuestionWithRelations[]>();
|
||||
const unsectionedQuestions: QuestionWithRelations[] = [];
|
||||
|
||||
for (const question of questions) {
|
||||
if (question.exerciseSectionId) {
|
||||
const sectionQuestions = questionsBySectionId.get(question.exerciseSectionId) ?? [];
|
||||
sectionQuestions.push(question);
|
||||
questionsBySectionId.set(question.exerciseSectionId, sectionQuestions);
|
||||
continue;
|
||||
}
|
||||
|
||||
unsectionedQuestions.push(question);
|
||||
}
|
||||
|
||||
return {
|
||||
sections: sections.map((section) => ({
|
||||
id: section.id,
|
||||
title: section.title,
|
||||
description: section.description,
|
||||
order: section.order,
|
||||
colorTheme: section.colorTheme,
|
||||
afterBehavior: section.afterBehavior,
|
||||
questions: questionsBySectionId.get(section.id) ?? []
|
||||
})),
|
||||
unsectionedQuestions
|
||||
};
|
||||
}
|
||||
|
||||
async function syncExerciseSections(
|
||||
exerciseId: string,
|
||||
sections: NonNullable<TExerciseUpdate['sections']>,
|
||||
txClient: DbOrTxClient
|
||||
) {
|
||||
const currentSections = await getExerciseSectionsByExerciseId(exerciseId, txClient);
|
||||
const currentSectionIds = new Set(currentSections.map((section) => section.id));
|
||||
const incomingSectionIds = new Set(sections.map((section) => section.id).filter((id): id is string => !!id));
|
||||
|
||||
for (const section of sections) {
|
||||
if (section.afterBehavior.action !== 'go_to_section') continue;
|
||||
if (!incomingSectionIds.has(section.afterBehavior.exerciseSectionId)) {
|
||||
throw new AppError('Section routing target must exist in this exercise', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
}
|
||||
|
||||
const sectionsToDelete = currentSections
|
||||
.filter((section) => !incomingSectionIds.has(section.id))
|
||||
.map((section) => section.id);
|
||||
|
||||
if (sectionsToDelete.length > 0) {
|
||||
await deleteExerciseSectionsByIds(sectionsToDelete, txClient);
|
||||
}
|
||||
|
||||
const sectionsToCreate = sections
|
||||
.filter((section) => !section.id || !currentSectionIds.has(section.id))
|
||||
.map((section) => ({
|
||||
id: section.id,
|
||||
exerciseId,
|
||||
title: section.title,
|
||||
description: section.description ?? null,
|
||||
order: section.order,
|
||||
colorTheme: section.colorTheme,
|
||||
afterBehavior: section.afterBehavior
|
||||
}));
|
||||
|
||||
if (sectionsToCreate.length > 0) {
|
||||
await createExerciseSections(sectionsToCreate, txClient);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
sections
|
||||
.filter((section) => section.id && currentSectionIds.has(section.id))
|
||||
.map((section) =>
|
||||
updateExerciseSection(
|
||||
section.id!,
|
||||
{
|
||||
title: section.title,
|
||||
description: section.description ?? null,
|
||||
order: section.order,
|
||||
colorTheme: section.colorTheme,
|
||||
afterBehavior: section.afterBehavior
|
||||
},
|
||||
txClient
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function validateSectionQuestionAssignments(data: TExerciseUpdate) {
|
||||
if (!data.sections || data.sections.length === 0 || !data.questions) return;
|
||||
|
||||
const sectionIds = new Set(data.sections.map((section) => section.id).filter((id): id is string => !!id));
|
||||
const unassignedQuestion = data.questions.find(
|
||||
(question) => !question.deletedAt && (!question.exerciseSectionId || !sectionIds.has(question.exerciseSectionId))
|
||||
);
|
||||
|
||||
if (unassignedQuestion) {
|
||||
throw new AppError(
|
||||
'All active questions must be assigned to an exercise section',
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
400
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveExerciseCourseId(exercise: TExercise): Promise<string | null> {
|
||||
if (exercise.courseId) {
|
||||
return exercise.courseId;
|
||||
@@ -89,6 +223,17 @@ export async function createExercise(data: TExerciseCreate): Promise<TExercise>
|
||||
try {
|
||||
const sanitizedData = sanitizeExercisePayload(data);
|
||||
|
||||
await guardNonAutoGradableQuestionsForCourseType({
|
||||
courseId: sanitizedData.courseId,
|
||||
questionTypeIds: (sanitizedData.questions ?? []).map((question) => question.questionTypeId)
|
||||
});
|
||||
|
||||
const slug = await resolveItemSlug({
|
||||
courseId: sanitizedData.courseId,
|
||||
title: sanitizedData.title,
|
||||
requestedSlug: sanitizedData.slug
|
||||
});
|
||||
|
||||
const exerciseData = {
|
||||
title: sanitizedData.title,
|
||||
description: sanitizedData.description ?? null,
|
||||
@@ -96,7 +241,8 @@ export async function createExercise(data: TExerciseCreate): Promise<TExercise>
|
||||
courseId: sanitizedData.courseId,
|
||||
sectionId: sanitizedData.sectionId || null,
|
||||
order: sanitizedData.order ?? null,
|
||||
dueBy: sanitizedData.dueBy || null
|
||||
dueBy: sanitizedData.dueBy || null,
|
||||
slug
|
||||
};
|
||||
|
||||
const [exercise] = await createExercises([exerciseData]);
|
||||
@@ -118,23 +264,26 @@ export async function createExercise(data: TExerciseCreate): Promise<TExercise>
|
||||
}));
|
||||
|
||||
const questions = await createQuestions(questionsData, txClient);
|
||||
const hasOptions = sanitizedData.questions!.some((question) => (question.options?.length ?? 0) > 0);
|
||||
|
||||
if (hasOptions) {
|
||||
await syncOptionIdSequence(txClient);
|
||||
}
|
||||
|
||||
// Create options for each question
|
||||
const optionsPromises = sanitizedData.questions!.map(async (q, index) => {
|
||||
for (const [index, questionData] of sanitizedData.questions!.entries()) {
|
||||
const question = questions[index];
|
||||
if (!question) return;
|
||||
if (!question) continue;
|
||||
|
||||
const optionsData: TNewOption[] = (q.options ?? []).map((opt) => ({
|
||||
const optionsData: TNewOption[] = (questionData.options ?? []).map((opt) => ({
|
||||
questionId: question.id,
|
||||
label: opt.label,
|
||||
isCorrect: opt.isCorrect,
|
||||
settings: opt.settings ?? {}
|
||||
}));
|
||||
|
||||
return createOptions(optionsData, txClient);
|
||||
});
|
||||
|
||||
await Promise.all(optionsPromises);
|
||||
await createOptions(optionsData, txClient);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -185,11 +334,14 @@ export async function getExercise(
|
||||
// Transform questions (strip questionType and options since they're handled in transform)
|
||||
const questionsForTransform = questions.map(({ questionType, options, ...q }) => q);
|
||||
const questionsWithOptions = transformQuestions(questionsForTransform, allOptions, questionTypeMap);
|
||||
const sections = await getExerciseSectionsByExerciseId(exerciseId, dbClient);
|
||||
const sectionGroups = groupQuestionsBySections(sections, questionsWithOptions);
|
||||
|
||||
return {
|
||||
...exercise,
|
||||
isComplete,
|
||||
questions: questionsWithOptions
|
||||
questions: questionsWithOptions,
|
||||
sections: sectionGroups.sections
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
@@ -214,9 +366,28 @@ export async function getExercise(
|
||||
export async function updateExerciseService(exerciseId: string, data: TExerciseUpdate): Promise<TExercise> {
|
||||
try {
|
||||
const sanitizedData = sanitizeExercisePayload(data);
|
||||
validateSectionQuestionAssignments(sanitizedData);
|
||||
|
||||
const existing = await getExerciseById(exerciseId);
|
||||
|
||||
if (existing?.courseId) {
|
||||
await guardNonAutoGradableQuestionsForCourseType({
|
||||
courseId: existing.courseId,
|
||||
questionTypeIds: (sanitizedData.questions ?? [])
|
||||
.filter((question) => !('deletedAt' in question && question.deletedAt))
|
||||
.map((question) => question.questionTypeId)
|
||||
});
|
||||
}
|
||||
|
||||
if (sanitizedData.slug !== undefined && existing?.courseId) {
|
||||
sanitizedData.slug = await resolveItemSlug({
|
||||
courseId: existing.courseId,
|
||||
title: sanitizedData.title ?? existing.title,
|
||||
requestedSlug: sanitizedData.slug,
|
||||
ignoreItemSlug: existing.slug ?? undefined
|
||||
});
|
||||
}
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const txClient = tx as DbOrTxClient;
|
||||
|
||||
@@ -229,6 +400,10 @@ export async function updateExerciseService(exerciseId: string, data: TExerciseU
|
||||
}
|
||||
}
|
||||
|
||||
if (sanitizedData.sections !== undefined) {
|
||||
await syncExerciseSections(exerciseId, sanitizedData.sections, txClient);
|
||||
}
|
||||
|
||||
// Process questions - only update what changed
|
||||
if (sanitizedData.questions && sanitizedData.questions.length > 0) {
|
||||
// Query current state from DB
|
||||
@@ -246,31 +421,42 @@ export async function updateExerciseService(exerciseId: string, data: TExerciseU
|
||||
// Compute diff between current DB state and incoming data
|
||||
const diff = computeExerciseDiff(currentQuestionsWithOptions, sanitizedData.questions);
|
||||
|
||||
// Sync option id sequence so next inserts get ids > max(id) (avoids option_pkey duplicate)
|
||||
if (
|
||||
diff.options.creates.length > 0 ||
|
||||
diff.questions.newQuestions.length > 0 ||
|
||||
diff.questions.updates.length > 0
|
||||
) {
|
||||
const hasNewQuestions = diff.questions.newQuestions.length > 0;
|
||||
const hasNewOptions = diff.options.creates.length > 0 || hasNewQuestions;
|
||||
|
||||
if (hasNewQuestions) {
|
||||
await syncQuestionIdSequence(txClient);
|
||||
}
|
||||
|
||||
if (hasNewOptions) {
|
||||
await syncOptionIdSequence(txClient);
|
||||
}
|
||||
|
||||
// Apply all changes in parallel (they operate on different records)
|
||||
await Promise.all([
|
||||
// Delete questions marked for deletion (cascade deletes their options)
|
||||
diff.questions.deletedIds.length > 0 && deleteQuestionsByIds(diff.questions.deletedIds, txClient),
|
||||
// Update only questions that have changed fields
|
||||
diff.questions.updates.length > 0 && updateQuestions(diff.questions.updates, txClient),
|
||||
// Delete options marked for deletion
|
||||
diff.options.deletedIds.length > 0 && deleteOptionsByIds(diff.options.deletedIds, txClient),
|
||||
// Update only options that have changed fields
|
||||
diff.options.updates.length > 0 && updateOptions(diff.options.updates, txClient),
|
||||
// Create new options for existing questions
|
||||
diff.options.creates.length > 0 && createOptions(diff.options.creates, txClient),
|
||||
// Create new questions with their options
|
||||
diff.questions.newQuestions.length > 0 &&
|
||||
createNewQuestionsWithOptions(exerciseId, diff.questions.newQuestions, txClient)
|
||||
]);
|
||||
// Apply question/option graph changes in dependency order. Question deletes cascade
|
||||
// options, while new question creation can also create options.
|
||||
if (diff.questions.deletedIds.length > 0) {
|
||||
await deleteQuestionsByIds(diff.questions.deletedIds, txClient);
|
||||
}
|
||||
|
||||
if (diff.options.deletedIds.length > 0) {
|
||||
await deleteOptionsByIds(diff.options.deletedIds, txClient);
|
||||
}
|
||||
|
||||
if (diff.questions.updates.length > 0) {
|
||||
await updateQuestions(diff.questions.updates, txClient);
|
||||
}
|
||||
|
||||
if (diff.options.updates.length > 0) {
|
||||
await updateOptions(diff.options.updates, txClient);
|
||||
}
|
||||
|
||||
if (diff.options.creates.length > 0) {
|
||||
await createOptions(diff.options.creates, txClient);
|
||||
}
|
||||
|
||||
if (hasNewQuestions) {
|
||||
await createNewQuestionsWithOptions(exerciseId, diff.questions.newQuestions, txClient);
|
||||
}
|
||||
}
|
||||
|
||||
// Return the updated exercise
|
||||
@@ -293,6 +479,142 @@ export async function updateExerciseService(exerciseId: string, data: TExerciseU
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateExerciseSectionMetadataService(
|
||||
exerciseId: string,
|
||||
exerciseSectionId: string,
|
||||
data: { title?: string; description?: string | null }
|
||||
) {
|
||||
try {
|
||||
const sections = await getExerciseSectionsByExerciseId(exerciseId);
|
||||
const belongs = sections.some((section) => section.id === exerciseSectionId);
|
||||
|
||||
if (!belongs) {
|
||||
throw new AppError(
|
||||
'That exercise section was not found on this exercise. Call get_exercise_details and use a section id from the sections array.',
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
const patch: { title?: string; description?: string | null } = {};
|
||||
|
||||
if (data.title !== undefined) {
|
||||
patch.title = sanitizeHtml(data.title);
|
||||
}
|
||||
|
||||
if (data.description !== undefined) {
|
||||
patch.description = data.description === '' ? null : (sanitizeOptionalHtml(data.description) ?? null);
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
throw new AppError('No fields to update', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
const updated = await updateExerciseSection(exerciseSectionId, patch);
|
||||
|
||||
if (!updated) {
|
||||
throw new AppError('Failed to update exercise section', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
const existing = await getExerciseById(exerciseId);
|
||||
|
||||
if (existing?.courseId) {
|
||||
await touchCourseUpdatedAt(existing.courseId);
|
||||
}
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
title: updated.title,
|
||||
description: updated.description ?? null,
|
||||
exerciseId
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('updateExerciseSectionMetadataService error:', error);
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to update exercise section',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createExerciseSectionService(
|
||||
exerciseId: string,
|
||||
data: {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
order: number;
|
||||
colorTheme?: 'blue' | 'green' | 'amber' | 'rose' | 'violet' | 'slate';
|
||||
afterBehavior?:
|
||||
| { action: 'continue' }
|
||||
| { action: 'submit' }
|
||||
| { action: 'go_to_section'; exerciseSectionId: string };
|
||||
}
|
||||
) {
|
||||
try {
|
||||
const sections = await getExerciseSectionsByExerciseId(exerciseId);
|
||||
const afterBehavior = data.afterBehavior;
|
||||
|
||||
if (afterBehavior?.action === 'go_to_section') {
|
||||
const targetExists = sections.some((section) => section.id === afterBehavior.exerciseSectionId);
|
||||
|
||||
if (!targetExists) {
|
||||
throw new AppError(
|
||||
'That exercise section was not found on this exercise. Call get_exercise_details and use a section id from the sections array.',
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
404
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const [created] = await createExerciseSections([
|
||||
{
|
||||
exerciseId,
|
||||
title: data.title,
|
||||
description: data.description ?? null,
|
||||
order: data.order,
|
||||
colorTheme: data.colorTheme ?? 'blue',
|
||||
afterBehavior: data.afterBehavior ?? { action: 'continue' }
|
||||
}
|
||||
]);
|
||||
|
||||
if (!created) {
|
||||
throw new AppError('Failed to create exercise section', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
const existing = await getExerciseById(exerciseId);
|
||||
|
||||
if (existing?.courseId) {
|
||||
await touchCourseUpdatedAt(existing.courseId);
|
||||
}
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
exerciseId: created.exerciseId,
|
||||
title: created.title,
|
||||
description: created.description ?? null,
|
||||
order: created.order,
|
||||
colorTheme: created.colorTheme,
|
||||
afterBehavior: created.afterBehavior
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('createExerciseSectionService error:', error);
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to create exercise section',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces an exercise's questions and options with the provided payload.
|
||||
* Existing questions are deleted and recreated from the replacement payload.
|
||||
@@ -327,6 +649,8 @@ export async function replaceExerciseService(exerciseId: string, data: TExercise
|
||||
const hasReplacementOptions = replacementQuestions.some((question) => (question.options?.length ?? 0) > 0);
|
||||
|
||||
if (replacementQuestions.length > 0) {
|
||||
await syncQuestionIdSequence(txClient);
|
||||
|
||||
if (hasReplacementOptions) {
|
||||
await syncOptionIdSequence(txClient);
|
||||
}
|
||||
@@ -494,6 +818,14 @@ export async function createExerciseFromTemplate(
|
||||
if (questions && questions.length > 0) {
|
||||
await db.transaction(async (tx) => {
|
||||
const txClient = tx as DbOrTxClient;
|
||||
const hasTemplateOptions = questions.some(
|
||||
(question) => question.question_type.id !== 3 && (question.options?.length ?? 0) > 0
|
||||
);
|
||||
|
||||
if (hasTemplateOptions) {
|
||||
await syncOptionIdSequence(txClient);
|
||||
}
|
||||
|
||||
for (const question of questions) {
|
||||
const { title, question_type, options, order, points } = question;
|
||||
const questionSettings = sanitizeUnknownStrings(
|
||||
|
||||
@@ -25,6 +25,7 @@ type ExerciseAnswer = Pick<
|
||||
|
||||
export type QuestionWithRelations = {
|
||||
id: number | string;
|
||||
exerciseSectionId?: string | null;
|
||||
value: string;
|
||||
name: string;
|
||||
title: string;
|
||||
@@ -80,6 +81,8 @@ export function buildExerciseUpdateFields(data: TExerciseUpdate): Partial<TExerc
|
||||
if (data.isUnlocked !== undefined) exerciseUpdate.isUnlocked = data.isUnlocked;
|
||||
if (data.dueBy !== undefined) exerciseUpdate.dueBy = data.dueBy || null;
|
||||
if (data.allowMultipleAttempts !== undefined) exerciseUpdate.allowMultipleAttempts = data.allowMultipleAttempts;
|
||||
if (data.sectionDisplayMode !== undefined) exerciseUpdate.sectionDisplayMode = data.sectionDisplayMode;
|
||||
if (data.slug !== undefined) exerciseUpdate.slug = data.slug;
|
||||
return exerciseUpdate;
|
||||
}
|
||||
|
||||
@@ -180,6 +183,10 @@ function diffQuestion(
|
||||
changes.order = incoming.order;
|
||||
hasChanges = true;
|
||||
}
|
||||
if (incoming.exerciseSectionId !== undefined && incoming.exerciseSectionId !== current.exerciseSectionId) {
|
||||
changes.exerciseSectionId = incoming.exerciseSectionId;
|
||||
hasChanges = true;
|
||||
}
|
||||
if (!areSettingsEqual(current.settings, incoming.settings)) {
|
||||
changes.settings = normalizeSettings(incoming.settings);
|
||||
hasChanges = true;
|
||||
@@ -336,6 +343,7 @@ export async function createNewQuestionsWithOptions(
|
||||
questionTypeId: q.questionTypeId || 1,
|
||||
points: q.points || 0,
|
||||
order: typeof q.order === 'number' ? q.order : undefined,
|
||||
exerciseSectionId: q.exerciseSectionId ?? null,
|
||||
settings: normalizeSettings(q.settings)
|
||||
})),
|
||||
txClient
|
||||
@@ -411,6 +419,7 @@ export function transformQuestion(
|
||||
|
||||
return {
|
||||
id: question.id!,
|
||||
exerciseSectionId: question.exerciseSectionId ?? null,
|
||||
value: question.title || '', // value field defaults to title
|
||||
name: question.name || '',
|
||||
title: question.title,
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { getStorageConfig } from '@api/config/storage';
|
||||
import { generateVideoDownloadPresignedUrls, generateVideoUploadPresignedUrl } from '@api/utils/s3';
|
||||
import { createAsset, getAssetById, updateAsset } from '@cio/db/queries/assets';
|
||||
import { getExerciseWithRelationsOptimized } from '@cio/db/queries/exercise';
|
||||
import { getQuestionAnswersBySubmissionId, getSubmissionById } from '@cio/db/queries/submission';
|
||||
import { getGroupMemberIdByCourseAndProfile, isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group';
|
||||
import { getCourseOrganizationId as getCourseOrganizationIdFromDb } from '@cio/db/queries/tag';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { VideoRecordingAnswerData } from '@cio/question-types';
|
||||
import {
|
||||
QUESTION_TYPE_IDS,
|
||||
VIDEO_RECORDING_MIN_DURATION_SECONDS,
|
||||
getVideoRecordingMaxDurationSeconds
|
||||
} from '@cio/question-types';
|
||||
import type {
|
||||
TExerciseVideoRecordingUploadComplete,
|
||||
TExerciseVideoRecordingUploadInit
|
||||
} from '@cio/utils/validation/exercise';
|
||||
|
||||
type RecordingContext = {
|
||||
organizationId: string;
|
||||
groupMemberId: string;
|
||||
maxDurationSeconds: number;
|
||||
};
|
||||
|
||||
function getExtensionFromMimeType(mimeType: string): string {
|
||||
if (mimeType.includes('mp4')) return 'mp4';
|
||||
if (mimeType.includes('quicktime')) return 'mov';
|
||||
return 'webm';
|
||||
}
|
||||
|
||||
async function getCourseOrganizationIdResolved(courseId: string): Promise<string> {
|
||||
const organizationId = await getCourseOrganizationIdFromDb(courseId);
|
||||
|
||||
if (!organizationId) {
|
||||
throw new AppError('Course organization not found', ErrorCodes.NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return organizationId;
|
||||
}
|
||||
|
||||
async function getRecordingContext(
|
||||
courseId: string,
|
||||
exerciseId: string,
|
||||
questionId: number,
|
||||
profileId: string
|
||||
): Promise<RecordingContext> {
|
||||
const [organizationId, groupMemberId, exerciseWithRelations] = await Promise.all([
|
||||
getCourseOrganizationIdResolved(courseId),
|
||||
getGroupMemberIdByCourseAndProfile(courseId, profileId),
|
||||
getExerciseWithRelationsOptimized(exerciseId)
|
||||
]);
|
||||
|
||||
if (!groupMemberId) {
|
||||
throw new AppError('User is not a member of this course', ErrorCodes.UNAUTHORIZED, 403);
|
||||
}
|
||||
|
||||
const question = exerciseWithRelations.questions.find((item) => item.id === questionId);
|
||||
if (!question) {
|
||||
throw new AppError('Question not found for this exercise', ErrorCodes.NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
if (question.questionTypeId !== QUESTION_TYPE_IDS.VIDEO_RECORDING) {
|
||||
throw new AppError('Question is not a video recording question', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
return {
|
||||
organizationId,
|
||||
groupMemberId,
|
||||
maxDurationSeconds: getVideoRecordingMaxDurationSeconds(question.settings as Record<string, unknown> | null)
|
||||
};
|
||||
}
|
||||
|
||||
export async function initVideoRecordingUpload(
|
||||
courseId: string,
|
||||
exerciseId: string,
|
||||
questionId: number,
|
||||
profileId: string,
|
||||
input: TExerciseVideoRecordingUploadInit
|
||||
) {
|
||||
const context = await getRecordingContext(courseId, exerciseId, questionId, profileId);
|
||||
const extension = getExtensionFromMimeType(input.mimeType);
|
||||
const storageKey = [
|
||||
'exercise-recordings',
|
||||
context.organizationId,
|
||||
courseId,
|
||||
exerciseId,
|
||||
String(questionId),
|
||||
context.groupMemberId,
|
||||
`${randomUUID()}.${extension}`
|
||||
].join('/');
|
||||
|
||||
const asset = await createAsset({
|
||||
organizationId: context.organizationId,
|
||||
kind: 'video',
|
||||
provider: 'upload',
|
||||
storageProvider: 's3',
|
||||
storageKey,
|
||||
mimeType: input.mimeType,
|
||||
byteSize: input.size,
|
||||
title: input.fileName,
|
||||
status: 'pending',
|
||||
metadata: {
|
||||
source: 'exercise_video_recording',
|
||||
courseId,
|
||||
exerciseId,
|
||||
questionId,
|
||||
groupMemberId: context.groupMemberId,
|
||||
maxDurationSeconds: context.maxDurationSeconds
|
||||
},
|
||||
createdByProfileId: profileId
|
||||
});
|
||||
|
||||
const uploadUrl = await generateVideoUploadPresignedUrl(storageKey, input.mimeType);
|
||||
const expiresAt = new Date(Date.now() + getStorageConfig().presignUploadExpiresSeconds * 1000).toISOString();
|
||||
|
||||
return {
|
||||
assetId: asset.id,
|
||||
uploadUrl,
|
||||
storageKey,
|
||||
expiresAt
|
||||
};
|
||||
}
|
||||
|
||||
export async function completeVideoRecordingUpload(
|
||||
courseId: string,
|
||||
exerciseId: string,
|
||||
questionId: number,
|
||||
profileId: string,
|
||||
input: TExerciseVideoRecordingUploadComplete
|
||||
): Promise<VideoRecordingAnswerData> {
|
||||
const context = await getRecordingContext(courseId, exerciseId, questionId, profileId);
|
||||
if (input.durationSeconds < VIDEO_RECORDING_MIN_DURATION_SECONDS) {
|
||||
throw new AppError('Recording is too short', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
if (input.durationSeconds > context.maxDurationSeconds + 2) {
|
||||
throw new AppError('Recording exceeds the configured duration', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
|
||||
const asset = await getAssetById(input.assetId, context.organizationId);
|
||||
if (!asset || asset.storageKey !== input.storageKey) {
|
||||
throw new AppError('Recording asset not found', ErrorCodes.NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const uploadedAt = new Date().toISOString();
|
||||
|
||||
await updateAsset(input.assetId, context.organizationId, {
|
||||
status: 'active',
|
||||
storageKey: input.storageKey,
|
||||
mimeType: input.mimeType,
|
||||
byteSize: input.size,
|
||||
title: input.fileName,
|
||||
durationSeconds: Math.ceil(input.durationSeconds),
|
||||
metadata: {
|
||||
...(asset.metadata && typeof asset.metadata === 'object' && !Array.isArray(asset.metadata) ? asset.metadata : {}),
|
||||
recordedAt: input.recordedAt,
|
||||
uploadedAt,
|
||||
retakeCount: input.retakeCount ?? 0
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'VIDEO_RECORDING',
|
||||
assetId: input.assetId,
|
||||
storageKey: input.storageKey,
|
||||
fileName: input.fileName,
|
||||
mimeType: input.mimeType,
|
||||
size: input.size,
|
||||
durationSeconds: Math.ceil(input.durationSeconds),
|
||||
recordedAt: input.recordedAt,
|
||||
uploadedAt,
|
||||
provider: 'cloudflare',
|
||||
retakeCount: input.retakeCount ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
export async function getVideoRecordingPlaybackUrl(
|
||||
courseId: string,
|
||||
exerciseId: string,
|
||||
submissionId: string,
|
||||
questionId: number,
|
||||
profileId: string
|
||||
) {
|
||||
const [submission, groupMemberId, isReviewer] = await Promise.all([
|
||||
getSubmissionById(submissionId),
|
||||
getGroupMemberIdByCourseAndProfile(courseId, profileId),
|
||||
isCourseTeamMemberOrOrgAdmin(courseId, profileId)
|
||||
]);
|
||||
|
||||
if (!submission || submission.courseId !== courseId || submission.exerciseId !== exerciseId) {
|
||||
throw new AppError('Submission not found', ErrorCodes.NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
if (!isReviewer && (!groupMemberId || submission.submittedBy !== groupMemberId)) {
|
||||
throw new AppError('Unauthorized', ErrorCodes.UNAUTHORIZED, 403);
|
||||
}
|
||||
|
||||
const answers = await getQuestionAnswersBySubmissionId(submissionId);
|
||||
const answer = answers.find((item) => item.questionId === questionId);
|
||||
const answerData = answer?.answerData;
|
||||
if (!answerData || answerData.type !== 'VIDEO_RECORDING') {
|
||||
throw new AppError('Video recording answer not found', ErrorCodes.NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const urls = await generateVideoDownloadPresignedUrls([answerData.storageKey]);
|
||||
const playbackUrl = urls[answerData.storageKey];
|
||||
if (!playbackUrl) {
|
||||
throw new AppError('Video recording is unavailable', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
return {
|
||||
assetId: answerData.assetId,
|
||||
playbackUrl,
|
||||
expiresAt: new Date(Date.now() + getStorageConfig().presignDownloadExpiresSeconds * 1000).toISOString()
|
||||
};
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '@cio/db/queries/lesson';
|
||||
import { touchCourseUpdatedAt } from '@cio/db/queries/course';
|
||||
import { enrichLessonWithPresignedUrls } from '@api/utils/lesson-media';
|
||||
import { resolveItemSlug } from '@api/services/course/slug';
|
||||
|
||||
/**
|
||||
* Creates a new lesson
|
||||
@@ -29,9 +30,16 @@ import { enrichLessonWithPresignedUrls } from '@api/utils/lesson-media';
|
||||
*/
|
||||
export async function createLesson(courseId: string, data: TLessonCreate): Promise<TLesson> {
|
||||
try {
|
||||
const slug = await resolveItemSlug({
|
||||
courseId,
|
||||
title: data.title,
|
||||
requestedSlug: data.slug
|
||||
});
|
||||
|
||||
const lessonData = {
|
||||
...data,
|
||||
courseId
|
||||
courseId,
|
||||
slug
|
||||
};
|
||||
|
||||
const [lesson] = await createLessons([lessonData]);
|
||||
@@ -89,7 +97,26 @@ export async function getLesson(lessonId: string): Promise<LessonById> {
|
||||
*/
|
||||
export async function updateLessonService(lessonId: string, data: TLessonUpdate): Promise<TLesson> {
|
||||
try {
|
||||
const updated = await updateLesson(lessonId, data);
|
||||
const payload: TLessonUpdate = { ...data };
|
||||
|
||||
if (payload.slug !== undefined) {
|
||||
const existing = await getLessonById(lessonId);
|
||||
if (!existing) {
|
||||
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
|
||||
}
|
||||
if (!existing.courseId) {
|
||||
throw new AppError('Lesson has no course', ErrorCodes.LESSON_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
payload.slug = await resolveItemSlug({
|
||||
courseId: existing.courseId,
|
||||
title: payload.title ?? existing.title,
|
||||
requestedSlug: payload.slug,
|
||||
ignoreItemSlug: existing.slug ?? undefined
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await updateLesson(lessonId, payload);
|
||||
if (!updated) {
|
||||
throw new AppError('Failed to update lesson', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
import * as schema from '@cio/db/schema';
|
||||
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { and, eq, isNull } from 'drizzle-orm';
|
||||
import {
|
||||
checkEmailsExistInOrg,
|
||||
claimPendingOrganizationInvite,
|
||||
createOrganizationInvite,
|
||||
createOrganizationInviteAudit,
|
||||
createOrganizationMember,
|
||||
createOrganizationMembers,
|
||||
getActivePendingOrgInviteForEmail,
|
||||
getOrganizationById,
|
||||
getOrganizationInviteByTokenHash,
|
||||
revokeActiveOrganizationInvitesByEmails
|
||||
revokeActiveOrganizationInvitesByEmails,
|
||||
selectOrganizationInviteWithOrgByInviteId,
|
||||
selectOrganizationInviteWithOrgByTokenHash,
|
||||
selectOrganizationMemberByOrgAndNormalizedEmail,
|
||||
selectOrganizationMemberByOrgAndProfile,
|
||||
updateOrganizationMemberById
|
||||
} from '@cio/db/queries/organization';
|
||||
import { getCourseGroupIds } from '@cio/db/queries/course';
|
||||
import { enrollUsersInCourseGroups } from '@cio/db/queries/group';
|
||||
import { addProgramMember, getExistingProgramMembers } from '@cio/db/queries/program';
|
||||
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import type { TNewOrganizationInviteAudit } from '@db/types';
|
||||
import crypto from 'node:crypto';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import { db, type DbOrTxClient } from '@cio/db/drizzle';
|
||||
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
||||
import { parseCourseIdsFromInviteMetadata, parseProgramIdsFromInviteMetadata } from '@api/utils/org';
|
||||
import { markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile';
|
||||
@@ -90,10 +95,59 @@ function getExpiryLabel(expiresAtIso: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
async function syncOrgMemberForOrgInvite(
|
||||
tx: DbOrTxClient,
|
||||
params: { organizationId: string; roleId: number; normalizedEmail: string; userId: string }
|
||||
): Promise<void> {
|
||||
const orgMemberByEmail = await selectOrganizationMemberByOrgAndNormalizedEmail(
|
||||
tx,
|
||||
params.organizationId,
|
||||
params.normalizedEmail
|
||||
);
|
||||
|
||||
if (orgMemberByEmail) {
|
||||
if (orgMemberByEmail.profileId && orgMemberByEmail.profileId !== params.userId) {
|
||||
throw new AppError('This invite is linked to another account', ErrorCodes.UNAUTHORIZED, 403);
|
||||
}
|
||||
|
||||
await updateOrganizationMemberById(tx, orgMemberByEmail.id, {
|
||||
profileId: params.userId,
|
||||
roleId: params.roleId,
|
||||
email: params.normalizedEmail,
|
||||
verified: true
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const orgMemberByProfile = await selectOrganizationMemberByOrgAndProfile(tx, params.organizationId, params.userId);
|
||||
|
||||
if (orgMemberByProfile) {
|
||||
await updateOrganizationMemberById(tx, orgMemberByProfile.id, {
|
||||
roleId: params.roleId,
|
||||
email: params.normalizedEmail,
|
||||
verified: true
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await createOrganizationMember(
|
||||
{
|
||||
organizationId: params.organizationId,
|
||||
roleId: params.roleId,
|
||||
profileId: params.userId,
|
||||
email: params.normalizedEmail,
|
||||
verified: true
|
||||
},
|
||||
tx
|
||||
);
|
||||
}
|
||||
|
||||
async function recordOrganizationInviteAudit(
|
||||
inviteId: string,
|
||||
organizationId: string,
|
||||
eventType: (typeof schema.organizationInviteEventType.enumValues)[number],
|
||||
eventType: TNewOrganizationInviteAudit['eventType'],
|
||||
context: {
|
||||
actorProfileId?: string | null;
|
||||
targetEmail?: string | null;
|
||||
@@ -281,19 +335,7 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
|
||||
const tokenHash = hashToken(token);
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.select({
|
||||
invite: schema.organizationInvite,
|
||||
organization: {
|
||||
id: schema.organization.id,
|
||||
name: schema.organization.name,
|
||||
siteName: schema.organization.siteName
|
||||
}
|
||||
})
|
||||
.from(schema.organizationInvite)
|
||||
.innerJoin(schema.organization, eq(schema.organizationInvite.organizationId, schema.organization.id))
|
||||
.where(eq(schema.organizationInvite.tokenHash, tokenHash))
|
||||
.limit(1);
|
||||
const row = await selectOrganizationInviteWithOrgByTokenHash(tx, tokenHash);
|
||||
|
||||
if (!row) {
|
||||
throw new AppError('Invalid invite link', ErrorCodes.NOT_FOUND, 404);
|
||||
@@ -321,82 +363,16 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
|
||||
};
|
||||
}
|
||||
|
||||
const [orgMemberByEmail] = await tx
|
||||
.select()
|
||||
.from(schema.organizationmember)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.organizationmember.organizationId, row.invite.organizationId),
|
||||
eq(schema.organizationmember.email, normalizedEmail)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (orgMemberByEmail) {
|
||||
if (orgMemberByEmail.profileId && orgMemberByEmail.profileId !== user.id) {
|
||||
throw new AppError('This invite is linked to another account', ErrorCodes.UNAUTHORIZED, 403);
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(schema.organizationmember)
|
||||
.set({
|
||||
profileId: user.id,
|
||||
roleId: row.invite.roleId,
|
||||
email: normalizedEmail,
|
||||
verified: true
|
||||
})
|
||||
.where(eq(schema.organizationmember.id, orgMemberByEmail.id));
|
||||
} else {
|
||||
const [orgMemberByProfile] = await tx
|
||||
.select()
|
||||
.from(schema.organizationmember)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.organizationmember.organizationId, row.invite.organizationId),
|
||||
eq(schema.organizationmember.profileId, user.id)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (orgMemberByProfile) {
|
||||
await tx
|
||||
.update(schema.organizationmember)
|
||||
.set({
|
||||
roleId: row.invite.roleId,
|
||||
email: normalizedEmail,
|
||||
verified: true
|
||||
})
|
||||
.where(eq(schema.organizationmember.id, orgMemberByProfile.id));
|
||||
} else {
|
||||
await tx.insert(schema.organizationmember).values({
|
||||
organizationId: row.invite.organizationId,
|
||||
roleId: row.invite.roleId,
|
||||
profileId: user.id,
|
||||
email: normalizedEmail,
|
||||
verified: true
|
||||
});
|
||||
}
|
||||
}
|
||||
await syncOrgMemberForOrgInvite(tx, {
|
||||
organizationId: row.invite.organizationId,
|
||||
roleId: row.invite.roleId,
|
||||
normalizedEmail,
|
||||
userId: user.id
|
||||
});
|
||||
|
||||
await markUserAndProfileEmailVerified(user.id, tx);
|
||||
|
||||
const [acceptedInvite] = await tx
|
||||
.update(schema.organizationInvite)
|
||||
.set({
|
||||
acceptedAt: new Date().toISOString(),
|
||||
acceptedByProfileId: user.id,
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(schema.organizationInvite.id, row.invite.id),
|
||||
eq(schema.organizationInvite.isRevoked, false),
|
||||
isNull(schema.organizationInvite.acceptedAt)
|
||||
)
|
||||
)
|
||||
.returning({
|
||||
id: schema.organizationInvite.id
|
||||
});
|
||||
const acceptedInvite = await claimPendingOrganizationInvite(tx, row.invite.id, user.id);
|
||||
|
||||
if (!acceptedInvite) {
|
||||
throw new AppError('Invite is no longer available', ErrorCodes.VALIDATION_ERROR, 409);
|
||||
@@ -523,19 +499,7 @@ export async function acceptOrganizationInviteById(
|
||||
const normalizedEmail = user.email.toLowerCase().trim();
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.select({
|
||||
invite: schema.organizationInvite,
|
||||
organization: {
|
||||
id: schema.organization.id,
|
||||
name: schema.organization.name,
|
||||
siteName: schema.organization.siteName
|
||||
}
|
||||
})
|
||||
.from(schema.organizationInvite)
|
||||
.innerJoin(schema.organization, eq(schema.organizationInvite.organizationId, schema.organization.id))
|
||||
.where(eq(schema.organizationInvite.id, inviteId))
|
||||
.limit(1);
|
||||
const row = await selectOrganizationInviteWithOrgByInviteId(tx, inviteId);
|
||||
|
||||
if (!row) {
|
||||
throw new AppError('Invalid invite', ErrorCodes.NOT_FOUND, 404);
|
||||
@@ -560,71 +524,16 @@ export async function acceptOrganizationInviteById(
|
||||
return { organization: row.organization, invite: row.invite, roleId: row.invite.roleId, alreadyAccepted: true };
|
||||
}
|
||||
|
||||
const [orgMemberByEmail] = await tx
|
||||
.select()
|
||||
.from(schema.organizationmember)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.organizationmember.organizationId, row.invite.organizationId),
|
||||
eq(schema.organizationmember.email, normalizedEmail)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (orgMemberByEmail) {
|
||||
if (orgMemberByEmail.profileId && orgMemberByEmail.profileId !== user.id) {
|
||||
throw new AppError('This invite is linked to another account', ErrorCodes.UNAUTHORIZED, 403);
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(schema.organizationmember)
|
||||
.set({ profileId: user.id, roleId: row.invite.roleId, email: normalizedEmail, verified: true })
|
||||
.where(eq(schema.organizationmember.id, orgMemberByEmail.id));
|
||||
} else {
|
||||
const [orgMemberByProfile] = await tx
|
||||
.select()
|
||||
.from(schema.organizationmember)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.organizationmember.organizationId, row.invite.organizationId),
|
||||
eq(schema.organizationmember.profileId, user.id)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (orgMemberByProfile) {
|
||||
await tx
|
||||
.update(schema.organizationmember)
|
||||
.set({ roleId: row.invite.roleId, email: normalizedEmail, verified: true })
|
||||
.where(eq(schema.organizationmember.id, orgMemberByProfile.id));
|
||||
} else {
|
||||
await tx.insert(schema.organizationmember).values({
|
||||
organizationId: row.invite.organizationId,
|
||||
roleId: row.invite.roleId,
|
||||
profileId: user.id,
|
||||
email: normalizedEmail,
|
||||
verified: true
|
||||
});
|
||||
}
|
||||
}
|
||||
await syncOrgMemberForOrgInvite(tx, {
|
||||
organizationId: row.invite.organizationId,
|
||||
roleId: row.invite.roleId,
|
||||
normalizedEmail,
|
||||
userId: user.id
|
||||
});
|
||||
|
||||
await markUserAndProfileEmailVerified(user.id, tx);
|
||||
|
||||
const [acceptedInvite] = await tx
|
||||
.update(schema.organizationInvite)
|
||||
.set({
|
||||
acceptedAt: new Date().toISOString(),
|
||||
acceptedByProfileId: user.id,
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(schema.organizationInvite.id, row.invite.id),
|
||||
eq(schema.organizationInvite.isRevoked, false),
|
||||
isNull(schema.organizationInvite.acceptedAt)
|
||||
)
|
||||
)
|
||||
.returning({ id: schema.organizationInvite.id });
|
||||
const acceptedInvite = await claimPendingOrganizationInvite(tx, row.invite.id, user.id);
|
||||
|
||||
if (!acceptedInvite) {
|
||||
throw new AppError('Invite is no longer available', ErrorCodes.VALIDATION_ERROR, 409);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@cio/utils/validation/program';
|
||||
import {
|
||||
addCourseToProgram,
|
||||
addProgramMember,
|
||||
createProgramWithCreatorMembership,
|
||||
createProgramNewsfeed as createProgramNewsfeedQuery,
|
||||
createProgramNewsfeedComment as createProgramNewsfeedCommentQuery,
|
||||
@@ -39,10 +40,11 @@ import {
|
||||
updateProgramNewsfeedReaction
|
||||
} from '@cio/db/queries/program';
|
||||
import { getCourseGroupIds } from '@cio/db/queries/course';
|
||||
import { insertGroupMembersOnConflictDoNothing } from '@cio/db/queries/group';
|
||||
import { getProfileByEmail } from '@cio/db/queries/auth';
|
||||
import { insertOrganizationMembersOnConflictDoNothing } from '@cio/db/queries/organization';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
type ProgramMemberEnrollment = {
|
||||
profileId: string;
|
||||
@@ -80,8 +82,8 @@ async function enrollProgramStudentsInGroups(
|
||||
);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(schema.organizationmember).values(organizationMemberRows).onConflictDoNothing();
|
||||
await tx.insert(schema.groupmember).values(groupMemberRows).onConflictDoNothing();
|
||||
await insertOrganizationMembersOnConflictDoNothing(organizationMemberRows, tx);
|
||||
await insertGroupMembersOnConflictDoNothing(groupMemberRows, tx);
|
||||
});
|
||||
|
||||
return groupMemberRows.length;
|
||||
@@ -230,41 +232,41 @@ export async function addProgramMembers(programId: string, data: TAddProgramMemb
|
||||
}
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const [member] = await tx
|
||||
.insert(schema.programMember)
|
||||
.values({
|
||||
const member = await addProgramMember(
|
||||
{
|
||||
programId,
|
||||
profileId,
|
||||
roleId,
|
||||
email: normalizedEmail ?? undefined
|
||||
})
|
||||
.returning();
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
if (member && member.roleId === ROLE.STUDENT && member.profileId && courseGroupIds.length > 0) {
|
||||
const validCourseGroupIds = courseGroupIds.filter((groupId): groupId is string => Boolean(groupId));
|
||||
|
||||
await tx
|
||||
.insert(schema.organizationmember)
|
||||
.values({
|
||||
organizationId: program.organizationId,
|
||||
roleId: ROLE.STUDENT,
|
||||
profileId: member.profileId,
|
||||
email: normalizedEmail ?? undefined,
|
||||
verified: true
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
await tx
|
||||
.insert(schema.groupmember)
|
||||
.values(
|
||||
validCourseGroupIds.map((groupId) => ({
|
||||
groupId,
|
||||
await insertOrganizationMembersOnConflictDoNothing(
|
||||
[
|
||||
{
|
||||
organizationId: program.organizationId,
|
||||
roleId: ROLE.STUDENT,
|
||||
profileId: member.profileId,
|
||||
email: normalizedEmail ?? undefined
|
||||
}))
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
email: normalizedEmail ?? undefined,
|
||||
verified: true
|
||||
}
|
||||
],
|
||||
tx
|
||||
);
|
||||
|
||||
await insertGroupMembersOnConflictDoNothing(
|
||||
validCourseGroupIds.map((groupId) => ({
|
||||
groupId,
|
||||
roleId: ROLE.STUDENT,
|
||||
profileId: member.profileId,
|
||||
email: normalizedEmail ?? undefined
|
||||
})),
|
||||
tx
|
||||
);
|
||||
}
|
||||
|
||||
return member;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { AnswerData, FileUploadAnswerData } from '@cio/question-types';
|
||||
import { scoreSubmissionAnswers, type ScoreSubmissionResult, validateTextareaAnswer } from '@cio/question-types';
|
||||
import type { AnswerData, FileUploadAnswerData, VideoRecordingAnswerData } from '@cio/question-types';
|
||||
import {
|
||||
getVideoRecordingMaxDurationSeconds,
|
||||
scoreSubmissionAnswers,
|
||||
type ScoreSubmissionResult,
|
||||
validateTextareaAnswer
|
||||
} from '@cio/question-types';
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import type { TNewQuestionAnswer, TNewSubmission, TSubmission } from '@cio/db/types';
|
||||
import type {
|
||||
@@ -21,6 +26,7 @@ import {
|
||||
updateSubmission,
|
||||
updateSubmissionGrades
|
||||
} from '@cio/db/queries/submission';
|
||||
import { createAssetUsage, getAssetById } from '@cio/db/queries/assets';
|
||||
import {
|
||||
fromApiPayload,
|
||||
getQuestionTypeById,
|
||||
@@ -35,7 +41,7 @@ import { getGroupMemberIdByCourseAndProfile, isCourseTeamMemberOrOrgAdmin } from
|
||||
import { QUESTION_TYPE_ID_TO_KEY } from '@cio/question-types';
|
||||
|
||||
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
||||
import { generateDocumentDownloadPresignedUrls } from '@api/utils/s3';
|
||||
import { generateDocumentDownloadPresignedUrls, generateVideoDownloadPresignedUrls } from '@api/utils/s3';
|
||||
import { syncComplianceProgressFromSubmission } from '@api/services/course/compliance';
|
||||
|
||||
type SubmissionGradingState = 'queued' | 'processing' | 'awaiting_manual' | 'completed' | 'failed';
|
||||
@@ -139,6 +145,10 @@ function isFileUpload(data: unknown): data is FileUploadAnswerData {
|
||||
return !!data && typeof data === 'object' && (data as { type: string }).type === 'FILE_UPLOAD';
|
||||
}
|
||||
|
||||
function isVideoRecording(data: unknown): data is VideoRecordingAnswerData {
|
||||
return !!data && typeof data === 'object' && (data as { type: string }).type === 'VIDEO_RECORDING';
|
||||
}
|
||||
|
||||
/**
|
||||
* Enriches FILE_UPLOAD answers from DB rows (question_answer[]) with presigned download URLs.
|
||||
* Used by getSubmission and listSubmissionsByExercise.
|
||||
@@ -148,15 +158,27 @@ async function enrichFileUploadAnswersArray<T extends { answerData?: unknown }>(
|
||||
.map((a) => a.answerData)
|
||||
.filter(isFileUpload)
|
||||
.map((d) => d.fileKey);
|
||||
const videoKeys = answers
|
||||
.map((a) => a.answerData)
|
||||
.filter(isVideoRecording)
|
||||
.map((d) => d.storageKey);
|
||||
|
||||
if (fileKeys.length === 0) return answers;
|
||||
if (fileKeys.length === 0 && videoKeys.length === 0) return answers;
|
||||
|
||||
try {
|
||||
const urls = await generateDocumentDownloadPresignedUrls(fileKeys);
|
||||
const [documentUrls, videoUrls] = await Promise.all([
|
||||
generateDocumentDownloadPresignedUrls(fileKeys),
|
||||
generateVideoDownloadPresignedUrls(videoKeys)
|
||||
]);
|
||||
return answers.map((answer) => {
|
||||
if (!isFileUpload(answer.answerData)) return answer;
|
||||
const url = urls[answer.answerData.fileKey];
|
||||
return url ? { ...answer, answerData: { ...answer.answerData, fileUrl: url } } : answer;
|
||||
if (isFileUpload(answer.answerData)) {
|
||||
const url = documentUrls[answer.answerData.fileKey];
|
||||
return url ? { ...answer, answerData: { ...answer.answerData, fileUrl: url } } : answer;
|
||||
}
|
||||
|
||||
if (!isVideoRecording(answer.answerData)) return answer;
|
||||
const url = videoUrls[answer.answerData.storageKey];
|
||||
return url ? { ...answer, answerData: { ...answer.answerData, playbackUrl: url } } : answer;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('enrichFileUploadAnswersArray error:', error);
|
||||
@@ -172,16 +194,27 @@ async function enrichFileUploadAnswersObject(answers: Record<string, AnswerData>
|
||||
const fileKeys = Object.values(answers)
|
||||
.filter(isFileUpload)
|
||||
.map((d) => d.fileKey);
|
||||
const videoKeys = Object.values(answers)
|
||||
.filter(isVideoRecording)
|
||||
.map((d) => d.storageKey);
|
||||
|
||||
if (fileKeys.length === 0) return answers;
|
||||
if (fileKeys.length === 0 && videoKeys.length === 0) return answers;
|
||||
|
||||
try {
|
||||
const urls = await generateDocumentDownloadPresignedUrls(fileKeys);
|
||||
const [documentUrls, videoUrls] = await Promise.all([
|
||||
generateDocumentDownloadPresignedUrls(fileKeys),
|
||||
generateVideoDownloadPresignedUrls(videoKeys)
|
||||
]);
|
||||
return Object.fromEntries(
|
||||
Object.entries(answers).map(([key, data]) => {
|
||||
if (!isFileUpload(data)) return [key, data];
|
||||
const url = urls[data.fileKey];
|
||||
return url ? [key, { ...data, fileUrl: url }] : [key, data];
|
||||
if (isFileUpload(data)) {
|
||||
const url = documentUrls[data.fileKey];
|
||||
return url ? [key, { ...data, fileUrl: url }] : [key, data];
|
||||
}
|
||||
|
||||
if (!isVideoRecording(data)) return [key, data];
|
||||
const url = videoUrls[data.storageKey];
|
||||
return url ? [key, { ...data, playbackUrl: url }] : [key, data];
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -190,6 +223,30 @@ async function enrichFileUploadAnswersObject(answers: Record<string, AnswerData>
|
||||
}
|
||||
}
|
||||
|
||||
async function createVideoRecordingAssetUsages(answers: Array<TNewQuestionAnswer & { id?: number }>): Promise<void> {
|
||||
const videoAnswers = answers.filter((answer) => isVideoRecording(answer.answerData));
|
||||
if (videoAnswers.length === 0) return;
|
||||
|
||||
await Promise.all(
|
||||
videoAnswers.map(async (answer) => {
|
||||
if (!answer.id || !isVideoRecording(answer.answerData)) return;
|
||||
|
||||
const asset = await getAssetById(answer.answerData.assetId);
|
||||
if (!asset) return;
|
||||
|
||||
await createAssetUsage({
|
||||
organizationId: asset.organizationId,
|
||||
assetId: answer.answerData.assetId,
|
||||
targetType: 'submission_answer',
|
||||
targetId: String(answer.id),
|
||||
slotType: 'video_recording_answer',
|
||||
slotKey: String(answer.questionId),
|
||||
createdByProfileId: null
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a submission by ID with question answers
|
||||
* @param submissionId Submission ID
|
||||
@@ -563,6 +620,13 @@ export async function createSubmissionService(
|
||||
}
|
||||
}
|
||||
|
||||
if (answerData.type === 'VIDEO_RECORDING') {
|
||||
const maxDurationSeconds = getVideoRecordingMaxDurationSeconds(question.settings);
|
||||
if (answerData.durationSeconds > maxDurationSeconds + 2) {
|
||||
throw new AppError('Recording exceeds the configured duration', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
}
|
||||
|
||||
answerByQuestionId.set(ans.questionId, answerData);
|
||||
|
||||
answerRows.push({
|
||||
@@ -594,6 +658,7 @@ export async function createSubmissionService(
|
||||
}
|
||||
|
||||
const insertedAnswers = answerRows.length > 0 ? await insertQuestionAnswersBatch(answerRows) : [];
|
||||
await createVideoRecordingAssetUsages(insertedAnswers);
|
||||
|
||||
if (shouldAutoGrade) {
|
||||
const dbQuestions = exerciseWithRelations.questions
|
||||
|
||||
@@ -40,3 +40,8 @@ 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
|
||||
|
||||
# Polar (SvelteKit routes: /api/polar/subscribe, portal, webhook, buy-tokens)
|
||||
POLAR_ACCESS_TOKEN=
|
||||
POLAR_WEBHOOK_SECRET=
|
||||
POLAR_TOKEN_PACK_PRODUCT_ID=
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
"@cio/ui": "workspace:*",
|
||||
"@cio/utils": "workspace:*",
|
||||
"@lucide/svelte": "^0.545.0",
|
||||
"@polar-sh/sveltekit": "^0.2.1",
|
||||
"@polar-sh/sdk": "^0.47.1",
|
||||
"@polar-sh/sveltekit": "^0.7.5",
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/adapter-node": "^2.0.0",
|
||||
"@sveltejs/adapter-vercel": "^4.0.0",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import type { TCustomLinks } from './types';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { TAgentCreditPurchase } from '@cio/utils/validation/agent';
|
||||
|
||||
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
|
||||
import { getApiKeyHeaders, safeServerApi } from '$lib/utils/services/api/server';
|
||||
|
||||
type RecordPurchaseRequest = typeof classroomio.agent.credits.purchase.$post;
|
||||
type RecordPurchaseSuccess = Extract<InferResponseType<RecordPurchaseRequest>, { success: true }>;
|
||||
|
||||
export class CreditPurchaseApiServer {
|
||||
/**
|
||||
* Record a token-pack purchase from the Polar webhook (server-side, API key auth).
|
||||
*/
|
||||
static async recordPurchase(params: TAgentCreditPurchase) {
|
||||
const result = await safeServerApi<RecordPurchaseSuccess>(() =>
|
||||
classroomio.agent.credits.purchase.$post({ json: params }, getApiKeyHeaders())
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
console.error('Error recording credit purchase (server):', result);
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.body.data;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { onDestroy, onMount, tick } 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';
|
||||
@@ -13,7 +13,13 @@
|
||||
isAgentToolPart,
|
||||
type AgentToolPart
|
||||
} from '$features/ai-assistant/utils/tool-parts';
|
||||
import { isAiAssistantOpen } from '$features/ai-assistant/utils/store';
|
||||
import {
|
||||
isAiAssistantOpen,
|
||||
initialChatModel,
|
||||
initialChatPrompt,
|
||||
clearInitialChatModel,
|
||||
clearInitialChatPrompt
|
||||
} from '$features/ai-assistant/utils/store';
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Chat } from '@ai-sdk/svelte';
|
||||
@@ -23,9 +29,15 @@
|
||||
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 { openUpgradeModal } from '$lib/utils/functions/org';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
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 type {
|
||||
AiAssistantMessage,
|
||||
AiAssistantMessageMetadata,
|
||||
UploadedDocument
|
||||
} from '$features/ai-assistant/utils/types';
|
||||
import {
|
||||
AI_CHAT_MODEL_STORAGE_KEY,
|
||||
AI_COURSE_CHAT_DEFAULT_WIDTH,
|
||||
@@ -33,7 +45,22 @@
|
||||
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';
|
||||
import {
|
||||
AGENT_MODELS,
|
||||
AGENT_MODEL_IDS,
|
||||
DEFAULT_PICKER_MODEL_ID,
|
||||
UI_PICKER_MODEL_IDS,
|
||||
type AgentModelId
|
||||
} from '@cio/utils/agent-models';
|
||||
|
||||
const CONTINUE_IMPLEMENTATION_PROMPT = 'Continue implementing the plan from where you left off.';
|
||||
|
||||
interface Props {
|
||||
/** Course id from route / layout `data` (authoritative for this page). */
|
||||
courseId: string;
|
||||
}
|
||||
|
||||
let { courseId }: Props = $props();
|
||||
|
||||
// Extract current lessonId/exerciseId from route params
|
||||
const currentLessonId = $derived(page.params?.lessonId as string | undefined);
|
||||
@@ -43,8 +70,8 @@
|
||||
let uploadedDocument: UploadedDocument | null = $state(null);
|
||||
let isUploading = $state(false);
|
||||
|
||||
let usageFetched = $state(false);
|
||||
let conversationsLoaded = $state(false);
|
||||
let statusFetchedForCourseId: string | null = $state(null);
|
||||
let conversationsLoadedForCourseId: string | null = $state(null);
|
||||
let activeConversationId: string | null = $state(null);
|
||||
let chatWidth = $state(AI_COURSE_CHAT_DEFAULT_WIDTH);
|
||||
let hasLoadedChatWidth = $state(false);
|
||||
@@ -52,12 +79,18 @@
|
||||
let stopChatResize: (() => void) | null = null;
|
||||
let chatShellElement: HTMLDivElement | null = null;
|
||||
let selectedModel: AgentModelId = $state(DEFAULT_PICKER_MODEL_ID);
|
||||
const paidModelIds = UI_PICKER_MODEL_IDS.filter((id) => !AGENT_MODELS[id].isFree);
|
||||
|
||||
function isAgentModelId(value: unknown): value is AgentModelId {
|
||||
return typeof value === 'string' && (AGENT_MODEL_IDS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function handleSelectModel(id: AgentModelId) {
|
||||
if ($isFreePlan && paidModelIds.includes(id)) {
|
||||
openUpgradeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
selectedModel = id;
|
||||
|
||||
try {
|
||||
@@ -67,8 +100,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleLockedModelSelect() {
|
||||
openUpgradeModal();
|
||||
}
|
||||
|
||||
const tokenUsage = $derived(aiAssistantApi.status?.usage ?? null);
|
||||
|
||||
$effect(() => {
|
||||
if (!$isFreePlan || !paidModelIds.includes(selectedModel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedModel = DEFAULT_PICKER_MODEL_ID;
|
||||
|
||||
try {
|
||||
localStorage.setItem(AI_CHAT_MODEL_STORAGE_KEY, DEFAULT_PICKER_MODEL_ID);
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
});
|
||||
|
||||
function clampChatWidth(width: number) {
|
||||
return Math.min(AI_COURSE_CHAT_MAX_WIDTH, Math.max(AI_COURSE_CHAT_MIN_WIDTH, width));
|
||||
}
|
||||
@@ -105,8 +156,6 @@
|
||||
}
|
||||
|
||||
async function loadConversation(conversationId: string) {
|
||||
const courseId = courseApi.course?.id;
|
||||
|
||||
if (!courseId) return;
|
||||
|
||||
await aiAssistantApi.loadConversation(conversationId);
|
||||
@@ -114,14 +163,19 @@
|
||||
const conversation = aiAssistantApi.currentConversation;
|
||||
|
||||
if (conversation) {
|
||||
chat.messages = (conversation.messages ?? []) as AiAssistantMessage[];
|
||||
const loadedMessages = (conversation.messages ?? []) as AiAssistantMessage[];
|
||||
// Only overwrite in-memory messages if the loaded conversation actually has saved
|
||||
// messages. An empty result means the conversation was just created and the first
|
||||
// message hasn't been persisted yet — overwriting would wipe the optimistic message
|
||||
// that chat.sendMessage already placed in the UI.
|
||||
if (loadedMessages.length > 0) {
|
||||
chat.messages = loadedMessages;
|
||||
}
|
||||
setActiveConversationId(courseId, conversationId);
|
||||
}
|
||||
}
|
||||
|
||||
async function startNewChat() {
|
||||
const courseId = courseApi.course?.id;
|
||||
|
||||
if (!courseId) return;
|
||||
|
||||
const created = await aiAssistantApi.createConversation(courseId);
|
||||
@@ -133,8 +187,6 @@
|
||||
}
|
||||
|
||||
async function handleDeleteConversation(conversationId: string) {
|
||||
const courseId = courseApi.course?.id;
|
||||
|
||||
if (!courseId) return;
|
||||
|
||||
await aiAssistantApi.deleteConversation(conversationId);
|
||||
@@ -145,21 +197,35 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch status and load conversations when panel opens
|
||||
// Fetch status and load conversations when the panel opens or route `courseId` changes
|
||||
$effect(() => {
|
||||
const courseId = courseApi.course?.id;
|
||||
if (!$isAiAssistantOpen) {
|
||||
statusFetchedForCourseId = null;
|
||||
conversationsLoadedForCourseId = null;
|
||||
|
||||
if ($isAiAssistantOpen && !usageFetched && courseId) {
|
||||
usageFetched = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!courseId) return;
|
||||
|
||||
if (statusFetchedForCourseId !== courseId) {
|
||||
statusFetchedForCourseId = courseId;
|
||||
aiAssistantApi.fetchStatus(courseId);
|
||||
}
|
||||
|
||||
if ($isAiAssistantOpen && !conversationsLoaded && courseId) {
|
||||
conversationsLoaded = true;
|
||||
if (conversationsLoadedForCourseId !== courseId) {
|
||||
conversationsLoadedForCourseId = courseId;
|
||||
|
||||
const savedId = getActiveConversationId(courseId);
|
||||
const listForCourseId = courseId;
|
||||
const savedId = getActiveConversationId(listForCourseId);
|
||||
|
||||
aiAssistantApi.listConversations(listForCourseId).then(() => {
|
||||
if (courseId !== listForCourseId) return;
|
||||
|
||||
// If the initial-prompt flow already created and claimed a conversation while
|
||||
// listConversations was in-flight, don't clobber it by loading another one.
|
||||
if (activeConversationId) return;
|
||||
|
||||
aiAssistantApi.listConversations(courseId).then(() => {
|
||||
if (savedId) {
|
||||
loadConversation(savedId);
|
||||
} else if (aiAssistantApi.conversations.length > 0) {
|
||||
@@ -167,20 +233,78 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!$isAiAssistantOpen) {
|
||||
usageFetched = false;
|
||||
conversationsLoaded = false;
|
||||
}
|
||||
});
|
||||
|
||||
function refreshCourseStateAfterChat() {
|
||||
const profileId = $profile.id;
|
||||
|
||||
// Force refetch course data so new sections/lessons/exercises show in the UI
|
||||
if (courseId && profileId) {
|
||||
void courseApi.refreshCourse(courseId, profileId);
|
||||
}
|
||||
|
||||
// Refresh current lesson content if viewing a lesson
|
||||
if (courseId && currentLessonId) {
|
||||
void lessonApi.get(courseId, currentLessonId);
|
||||
}
|
||||
|
||||
// Refresh current exercise if viewing an exercise
|
||||
if (courseId && currentExerciseId) {
|
||||
void refreshExercisePageData(courseId, currentExerciseId);
|
||||
}
|
||||
|
||||
// Refresh usage meter
|
||||
if (courseId) {
|
||||
void aiAssistantApi.fetchStatus(courseId);
|
||||
}
|
||||
}
|
||||
|
||||
async function persistFinishedChat(messages: AiAssistantMessage[], conversationId: string | null) {
|
||||
if (conversationId) {
|
||||
await aiAssistantApi.saveMessages(conversationId, messages);
|
||||
|
||||
// Generate a smart title after the first exchange (when title is still default)
|
||||
const activeConv = aiAssistantApi.conversations.find((c) => c.id === conversationId);
|
||||
const isDefaultTitle = !activeConv?.title || activeConv.title === 'New conversation';
|
||||
const firstUserMsg = messages.find((message) => message.role === 'user');
|
||||
const textPart = firstUserMsg?.parts?.find(
|
||||
(part): part is Extract<typeof part, { type: 'text' }> => part.type === 'text'
|
||||
);
|
||||
|
||||
if (isDefaultTitle && textPart?.text) {
|
||||
await aiAssistantApi.generateTitle(conversationId, textPart.text.slice(0, 500));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!courseId) return;
|
||||
|
||||
// No active conversation yet — create one and save
|
||||
const created = await aiAssistantApi.createConversation(courseId);
|
||||
|
||||
if (!created) return;
|
||||
|
||||
setActiveConversationId(courseId, created.id);
|
||||
await aiAssistantApi.saveMessages(created.id, messages);
|
||||
|
||||
const firstUserMsg = messages.find((message) => message.role === 'user');
|
||||
const textPart = firstUserMsg?.parts?.find(
|
||||
(part): part is Extract<typeof part, { type: 'text' }> => part.type === 'text'
|
||||
);
|
||||
|
||||
if (textPart?.text) {
|
||||
await aiAssistantApi.generateTitle(created.id, textPart.text.slice(0, 500));
|
||||
}
|
||||
}
|
||||
|
||||
const chat = new Chat({
|
||||
id: 'ai-assistant',
|
||||
transport: new DefaultChatTransport({
|
||||
api: `${getRequestBaseUrl()}/agent/chat`,
|
||||
credentials: 'include',
|
||||
body: () => ({
|
||||
courseId: courseApi.course?.id ?? '',
|
||||
courseId,
|
||||
model: selectedModel,
|
||||
context: {
|
||||
lessonId: page.params?.lessonId,
|
||||
@@ -190,64 +314,12 @@
|
||||
}),
|
||||
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);
|
||||
}
|
||||
|
||||
onFinish: () => {
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
refreshCourseStateAfterChat();
|
||||
void persistFinishedChat(chat.messages as AiAssistantMessage[], activeConversationId);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -255,8 +327,8 @@
|
||||
'Upload a document to create a course',
|
||||
'Draft a lesson',
|
||||
'Generate questions from this lesson',
|
||||
"Improve this lesson's text",
|
||||
'Summarize this lesson'
|
||||
'Summarize this lesson',
|
||||
'Help me publish this course'
|
||||
];
|
||||
|
||||
function handleSend() {
|
||||
@@ -274,8 +346,6 @@
|
||||
|
||||
// Create conversation eagerly before sending the first message
|
||||
if (!activeConversationId) {
|
||||
const courseId = courseApi.course?.id;
|
||||
|
||||
if (!courseId) return;
|
||||
|
||||
aiAssistantApi.createConversation(courseId).then((created) => {
|
||||
@@ -304,8 +374,6 @@
|
||||
}
|
||||
|
||||
async function handleFileSelect(file: File) {
|
||||
const courseId = courseApi.course?.id;
|
||||
|
||||
if (!courseId) return;
|
||||
|
||||
const conversationId = await ensureActiveConversation(courseId);
|
||||
@@ -339,8 +407,6 @@
|
||||
|
||||
inputValue = '';
|
||||
|
||||
const courseId = courseApi.course?.id;
|
||||
|
||||
if (!activeConversationId) {
|
||||
if (!courseId) return;
|
||||
|
||||
@@ -367,7 +433,7 @@
|
||||
}
|
||||
|
||||
function handleResume() {
|
||||
inputValue = 'Continue implementing the plan from where you left off.';
|
||||
inputValue = CONTINUE_IMPLEMENTATION_PROMPT;
|
||||
handleSend();
|
||||
}
|
||||
|
||||
@@ -393,6 +459,8 @@
|
||||
|
||||
if (!lastAssistantMsg) return null;
|
||||
|
||||
const continuation = (lastAssistantMsg.metadata as AiAssistantMessageMetadata | undefined)?.continuation;
|
||||
const reachedStepLimit = continuation?.reason === 'step_limit';
|
||||
const allToolParts = lastAssistantMsg.parts.filter((part: Record<string, unknown>) =>
|
||||
isAgentToolPart(part)
|
||||
) as AgentToolPart[];
|
||||
@@ -417,10 +485,10 @@
|
||||
});
|
||||
|
||||
const allDone = steps.every((s) => s.status === 'completed');
|
||||
const isStopped = !isStreaming && !allDone;
|
||||
const isStopped = !isStreaming && (!allDone || reachedStepLimit);
|
||||
|
||||
// Hide the card once the agent finishes cleanly — the text response takes over
|
||||
if (allDone && !isStreaming) return null;
|
||||
if (allDone && !isStreaming && !reachedStepLimit) return null;
|
||||
|
||||
const hasMutations = toolParts.some((part) => {
|
||||
const toolName = getAgentToolName(part);
|
||||
@@ -496,6 +564,30 @@
|
||||
clearChatResizeListeners();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const prompt = $initialChatPrompt;
|
||||
|
||||
if (!prompt || !$isAiAssistantOpen || !courseId) return;
|
||||
|
||||
const model = $initialChatModel;
|
||||
clearInitialChatPrompt();
|
||||
clearInitialChatModel();
|
||||
|
||||
tick().then(() => {
|
||||
if (model && isAgentModelId(model)) {
|
||||
if ($isFreePlan && paidModelIds.includes(model)) {
|
||||
openUpgradeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
handleSelectModel(model);
|
||||
}
|
||||
|
||||
inputValue = prompt;
|
||||
handleSend();
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!hasLoadedChatWidth) {
|
||||
return;
|
||||
@@ -525,7 +617,7 @@
|
||||
messages={chat.messages}
|
||||
status={chat.status}
|
||||
{isStreaming}
|
||||
courseId={courseApi.course?.id ?? ''}
|
||||
{courseId}
|
||||
{planExecutionState}
|
||||
{quickActions}
|
||||
onQuickAction={handleQuickAction}
|
||||
@@ -544,12 +636,14 @@
|
||||
{uploadedDocument}
|
||||
{mentionItems}
|
||||
{selectedModel}
|
||||
lockedModelIds={$isFreePlan ? paidModelIds : []}
|
||||
error={chat.error}
|
||||
onSend={handleSend}
|
||||
onStop={handleStop}
|
||||
onFileSelect={handleFileSelect}
|
||||
onRemoveDocument={handleRemoveDocument}
|
||||
onSelectModel={handleSelectModel}
|
||||
onLockedModelSelect={handleLockedModelSelect}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
|
||||
@@ -105,6 +105,23 @@ class AiAssistantApi extends BaseApiWithErrors {
|
||||
});
|
||||
}
|
||||
|
||||
async generateCourseMeta(prompt: string): Promise<{ title: string; description: string } | null> {
|
||||
let meta: { title: string; description: string } | null = null;
|
||||
|
||||
await this.execute<(typeof classroomio.agent)['generate-course-title']['$post']>({
|
||||
requestFn: () =>
|
||||
classroomio.agent['generate-course-title'].$post({
|
||||
json: { prompt }
|
||||
}),
|
||||
logContext: 'generating course meta',
|
||||
onSuccess: (result) => {
|
||||
meta = (result as { data: { title: string; description: string } }).data;
|
||||
}
|
||||
});
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
async uploadDocument(
|
||||
file: File,
|
||||
courseId: string,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { closeAiAssistant } from '$features/ai-assistant/utils/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
|
||||
interface Conversation {
|
||||
id: string;
|
||||
@@ -75,11 +76,19 @@
|
||||
|
||||
<Popover.Root bind:open={historyPopoverOpen}>
|
||||
<Popover.Trigger>
|
||||
<IconButton variant="outline" size="icon-xs" tooltip={$t('ai_assistant.chat_history')}>
|
||||
<HistoryIcon size={16} />
|
||||
</IconButton>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
{...props}
|
||||
aria-label={$t('ai_assistant.chat_history')}
|
||||
title={$t('ai_assistant.chat_history')}
|
||||
>
|
||||
<HistoryIcon size={16} />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="ui:p-0! w-72" align="end">
|
||||
<Popover.Content class="ui:p-0! z-200! w-72" align="center">
|
||||
<ChatHistoryPopover
|
||||
{conversations}
|
||||
{activeConversationId}
|
||||
@@ -94,12 +103,13 @@
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
{#if tokenUsage && tokenUsage.allowance > 0}
|
||||
{@const usagePercent = Math.min(100, Math.round((tokenUsage.used / tokenUsage.allowance) * 100))}
|
||||
{#if tokenUsage && tokenUsage.used + tokenUsage.remaining > 0}
|
||||
{@const totalBudget = tokenUsage.used + tokenUsage.remaining}
|
||||
{@const usagePercent = Math.min(100, Math.round((tokenUsage.used / totalBudget) * 100))}
|
||||
<div class="mt-2">
|
||||
<div class="ui:text-muted-foreground flex justify-between text-[10px]">
|
||||
<span
|
||||
>{tokenUsage.used.toLocaleString()} / {tokenUsage.allowance.toLocaleString()}
|
||||
>{tokenUsage.used.toLocaleString()} / {totalBudget.toLocaleString()}
|
||||
{$t('ai_assistant.tokens_label')}</span
|
||||
>
|
||||
<span>{usagePercent}%</span>
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
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 { resolve } from '$app/paths';
|
||||
import { currentOrgPath, isFreePlan } from '$lib/utils/store/org';
|
||||
import { openUpgradeModal } from '$lib/utils/functions/org';
|
||||
import ModelPicker from '$features/ai-assistant/model-picker.svelte';
|
||||
import { ModelPicker } from '@cio/ui/custom/model-picker';
|
||||
import type { AgentModelId } from '@cio/utils/agent-models';
|
||||
|
||||
interface UploadedDocument {
|
||||
@@ -28,11 +29,13 @@
|
||||
mentionItems: MentionItem[];
|
||||
uploadedDocument: UploadedDocument | null;
|
||||
selectedModel: AgentModelId;
|
||||
lockedModelIds?: readonly AgentModelId[];
|
||||
onSend: () => void;
|
||||
onStop: () => void;
|
||||
onFileSelect: (file: File) => void;
|
||||
onRemoveDocument: () => void;
|
||||
onSelectModel: (id: AgentModelId) => void;
|
||||
onLockedModelSelect?: (id: AgentModelId) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -44,11 +47,13 @@
|
||||
mentionItems,
|
||||
uploadedDocument,
|
||||
selectedModel,
|
||||
lockedModelIds = [],
|
||||
onSend,
|
||||
onStop,
|
||||
onFileSelect,
|
||||
onRemoveDocument,
|
||||
onSelectModel
|
||||
onSelectModel,
|
||||
onLockedModelSelect
|
||||
}: Props = $props();
|
||||
|
||||
let fileInputEl: HTMLInputElement | undefined = $state();
|
||||
@@ -110,11 +115,29 @@
|
||||
<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"
|
||||
class="flex flex-col gap-2 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 sm:flex-row sm:items-center sm:justify-between dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300"
|
||||
>
|
||||
{$t('ai_assistant.tokens_exhausted')}
|
||||
<span>{$t('ai_assistant.tokens_exhausted')}</span>
|
||||
{#if $currentOrgPath !== '#'}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
href={`${resolve(`${$currentOrgPath}/settings/ai-credits`)}#buy-tokens`}
|
||||
class="w-full shrink-0 sm:w-auto"
|
||||
>
|
||||
{$t('ai_assistant.tokens_exhausted_buy_more')}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
{#if isStreaming}
|
||||
<div
|
||||
class="mb-2 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.agent_running_warning')}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#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"
|
||||
@@ -181,7 +204,13 @@
|
||||
<SquareIcon size={12} />
|
||||
</Button>
|
||||
{:else}
|
||||
<ModelPicker value={selectedModel} disabled={isStreaming} onChange={onSelectModel} />
|
||||
<ModelPicker
|
||||
value={selectedModel}
|
||||
disabled={isStreaming}
|
||||
{lockedModelIds}
|
||||
onChange={onSelectModel}
|
||||
onLockedSelect={onLockedModelSelect}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ChatTextarea>
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
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 type { CoursePlan } from '$features/ai-assistant/utils/course-plan';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import {
|
||||
getAgentToolName,
|
||||
getAgentToolResult,
|
||||
@@ -42,6 +44,7 @@
|
||||
}
|
||||
|
||||
const messageAttachment = $derived((message.metadata as AiAssistantMessageMetadata | undefined)?.attachment);
|
||||
const tokenUsage = $derived((message.metadata as AiAssistantMessageMetadata | undefined)?.tokenUsage);
|
||||
|
||||
function isDeferredPlanPart(part: Record<string, unknown>) {
|
||||
if (!isAgentToolPart(part)) {
|
||||
@@ -108,7 +111,7 @@
|
||||
{@const toolStatus = getAgentToolStatus(part)}
|
||||
{@const errorText = getPartErrorText(part)}
|
||||
{#if toolName === 'generate_course_plan' && toolStatus === 'completed'}
|
||||
<PlanView plan={toolResult} onImplement={onImplementPlan} onAskChanges={onAskPlanChanges} />
|
||||
<PlanView plan={toolResult as CoursePlan} 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}
|
||||
@@ -148,7 +151,7 @@
|
||||
{@const errorText = getPartErrorText(part)}
|
||||
{#if toolName === 'generate_course_plan' && toolStatus === 'completed'}
|
||||
<div class="mt-3">
|
||||
<PlanView plan={toolResult} onImplement={onImplementPlan} onAskChanges={onAskPlanChanges} />
|
||||
<PlanView plan={toolResult as CoursePlan} 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">
|
||||
@@ -180,6 +183,15 @@
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if message.role === 'assistant' && tokenUsage}
|
||||
<div class="mt-1 flex justify-end">
|
||||
<span class="ui:text-muted-foreground text-[10px]">
|
||||
{tokenUsage.totalTokens.toLocaleString()}
|
||||
{$t('ai_assistant.tokens_label')}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
<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>
|
||||
@@ -6,6 +6,7 @@
|
||||
import PencilIcon from '@lucide/svelte/icons/pencil';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import type { CoursePlan } from './utils/course-plan';
|
||||
|
||||
/** Token cost estimation heuristics (tokens per item) */
|
||||
const TOKEN_COST_ESTIMATES = {
|
||||
@@ -14,25 +15,6 @@
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export interface CoursePlanSectionItem {
|
||||
type: 'lesson' | 'exercise';
|
||||
title: string;
|
||||
description: string;
|
||||
order: number;
|
||||
hasExercise: boolean;
|
||||
}
|
||||
|
||||
export interface CoursePlanSection {
|
||||
title: string;
|
||||
order: number;
|
||||
items: CoursePlanSectionItem[];
|
||||
}
|
||||
|
||||
export interface CoursePlan {
|
||||
title: string;
|
||||
sections: CoursePlanSection[];
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type { AgentModelId } from '@cio/utils/agent-models';
|
||||
|
||||
/** Controls whether the AI assistant right sidebar Sheet is open. */
|
||||
export const isAiAssistantOpen = writable(false);
|
||||
@@ -14,3 +15,23 @@ export function closeAiAssistant() {
|
||||
export function toggleAiAssistant() {
|
||||
isAiAssistantOpen.update((open) => !open);
|
||||
}
|
||||
|
||||
/** Carries a prompt from the home page course creator into the AI chat on first open. */
|
||||
export const initialChatPrompt = writable<string | null>(null);
|
||||
export const initialChatModel = writable<AgentModelId | null>(null);
|
||||
|
||||
export function setInitialChatPrompt(prompt: string) {
|
||||
initialChatPrompt.set(prompt);
|
||||
}
|
||||
|
||||
export function setInitialChatModel(model: AgentModelId) {
|
||||
initialChatModel.set(model);
|
||||
}
|
||||
|
||||
export function clearInitialChatPrompt() {
|
||||
initialChatPrompt.set(null);
|
||||
}
|
||||
|
||||
export function clearInitialChatModel() {
|
||||
initialChatModel.set(null);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ const TOOL_LABELS: Record<string, string> = {
|
||||
update_lesson: 'Updating lesson',
|
||||
update_lesson_content: 'Writing lesson content',
|
||||
create_exercise: 'Creating exercise',
|
||||
create_exercise_section: 'Creating exercise section',
|
||||
update_exercise: 'Updating exercise',
|
||||
update_exercise_section: 'Updating exercise section',
|
||||
add_questions: 'Adding questions',
|
||||
update_questions: 'Updating questions',
|
||||
reorder_content: 'Reordering content',
|
||||
@@ -40,6 +42,8 @@ export function getToolResultLabel(toolName: string, result: unknown): string {
|
||||
return `Created exercise with ${r.questionCount} questions`;
|
||||
case 'update_exercise':
|
||||
return `Updated exercise: ${r.title}`;
|
||||
case 'update_exercise_section':
|
||||
return `Updated exercise section: ${r.title}`;
|
||||
case 'add_questions':
|
||||
return `Added ${r.addedCount} questions`;
|
||||
case 'update_questions':
|
||||
@@ -78,7 +82,9 @@ export const MUTATION_TOOLS = [
|
||||
'update_lesson',
|
||||
'update_lesson_content',
|
||||
'create_exercise',
|
||||
'create_exercise_section',
|
||||
'update_exercise',
|
||||
'update_exercise_section',
|
||||
'add_questions',
|
||||
'update_questions',
|
||||
'reorder_content',
|
||||
|
||||
@@ -12,12 +12,18 @@ export interface AgentToolPart {
|
||||
};
|
||||
}
|
||||
|
||||
export function isAgentToolPart(part: Record<string, unknown>): part is AgentToolPart {
|
||||
if (part.type === 'tool-invocation') {
|
||||
export function isAgentToolPart(part: unknown): part is AgentToolPart {
|
||||
if (!part || typeof part !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const record = part as Record<string, unknown>;
|
||||
|
||||
if (record.type === 'tool-invocation') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return typeof part.type === 'string' && part.type.startsWith('tool-');
|
||||
return typeof record.type === 'string' && record.type.startsWith('tool-');
|
||||
}
|
||||
|
||||
export function getAgentToolName(part: AgentToolPart): string | null {
|
||||
|
||||
@@ -11,8 +11,22 @@ export interface AiAssistantMessageAttachment {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AiAssistantMessageTokenUsage {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
export interface AiAssistantMessageContinuation {
|
||||
reason: 'step_limit';
|
||||
maxSteps: number;
|
||||
finishReason?: string;
|
||||
}
|
||||
|
||||
export interface AiAssistantMessageMetadata {
|
||||
attachment?: AiAssistantMessageAttachment;
|
||||
tokenUsage?: AiAssistantMessageTokenUsage;
|
||||
continuation?: AiAssistantMessageContinuation;
|
||||
}
|
||||
|
||||
export type AiAssistantMessage = UIMessage<AiAssistantMessageMetadata>;
|
||||
@@ -47,3 +61,7 @@ export type AgentConversationCreateData = AgentConversationCreateSuccess['data']
|
||||
export type AgentHistorySaveRequest = (typeof classroomio.agent.history)[':conversationId']['$put'];
|
||||
|
||||
export type AgentHistoryDeleteRequest = (typeof classroomio.agent.history)[':conversationId']['$delete'];
|
||||
|
||||
export type GenerateCourseTitleRequest = (typeof classroomio.agent)['generate-course-title']['$post'];
|
||||
export type GenerateCourseTitleSuccess = Extract<InferResponseType<GenerateCourseTitleRequest>, { success: true }>;
|
||||
export type GenerateCourseTitleData = GenerateCourseTitleSuccess['data'];
|
||||
|
||||
@@ -349,7 +349,10 @@ export class CourseApi extends BaseApiWithErrors {
|
||||
* @param fields Course creation fields (title, description, type)
|
||||
* @returns The created course data or null on error
|
||||
*/
|
||||
async create(fields: { title: string; description: string; type: TCourseType }) {
|
||||
async create(
|
||||
fields: { title: string; description: string; type: TCourseType },
|
||||
onCreated?: (courseId: string) => void
|
||||
) {
|
||||
const org = get(currentOrg);
|
||||
const userProfile = get(profile);
|
||||
|
||||
@@ -390,11 +393,12 @@ export class CourseApi extends BaseApiWithErrors {
|
||||
user_email: userProfile.email
|
||||
});
|
||||
|
||||
// Navigate to the new course
|
||||
goto(resolve(`/courses/${newCourse.id}`, {}));
|
||||
|
||||
// Show success message
|
||||
snackbar.success('Course created successfully');
|
||||
if (onCreated) {
|
||||
onCreated(newCourse.id);
|
||||
} else {
|
||||
goto(resolve(`/courses/${newCourse.id}`, {}));
|
||||
snackbar.success('Course created successfully');
|
||||
}
|
||||
|
||||
// Mark as successful
|
||||
this.success = true;
|
||||
|
||||
@@ -6,10 +6,25 @@ import type {
|
||||
Exercise,
|
||||
GetExerciseRequest,
|
||||
SubmitExerciseRequest,
|
||||
UpdateExerciseRequest
|
||||
UpdateExerciseRequest,
|
||||
VideoRecordingPlaybackRequest,
|
||||
VideoRecordingUploadCompleteRequest,
|
||||
VideoRecordingUploadInitRequest
|
||||
} from '../utils/types';
|
||||
import type { TExerciseCreate, TExerciseSubmissionCreate, TExerciseUpdate } from '@cio/utils/validation/exercise';
|
||||
import { ZExerciseCreate, ZExerciseSubmissionCreate, ZExerciseUpdate } from '@cio/utils/validation/exercise';
|
||||
import type {
|
||||
TExerciseCreate,
|
||||
TExerciseSubmissionCreate,
|
||||
TExerciseUpdate,
|
||||
TExerciseVideoRecordingUploadComplete,
|
||||
TExerciseVideoRecordingUploadInit
|
||||
} from '@cio/utils/validation/exercise';
|
||||
import {
|
||||
ZExerciseCreate,
|
||||
ZExerciseSubmissionCreate,
|
||||
ZExerciseUpdate,
|
||||
ZExerciseVideoRecordingUploadComplete,
|
||||
ZExerciseVideoRecordingUploadInit
|
||||
} from '@cio/utils/validation/exercise';
|
||||
|
||||
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
@@ -32,7 +47,7 @@ export class ExerciseApi extends BaseApiWithErrors {
|
||||
logContext: 'fetching exercise',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.exercise = response.data as Exercise;
|
||||
this.exercise = response.data as unknown as Exercise;
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
@@ -64,7 +79,7 @@ export class ExerciseApi extends BaseApiWithErrors {
|
||||
logContext: 'creating exercise',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.exercise = response.data as Exercise;
|
||||
this.exercise = response.data as unknown as Exercise;
|
||||
snackbar.success('Exercise created successfully');
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
@@ -106,7 +121,7 @@ export class ExerciseApi extends BaseApiWithErrors {
|
||||
logContext: 'updating exercise',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.exercise = response.data as Exercise;
|
||||
this.exercise = response.data as unknown as Exercise;
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
@@ -193,6 +208,71 @@ export class ExerciseApi extends BaseApiWithErrors {
|
||||
});
|
||||
}
|
||||
|
||||
async initVideoRecordingUpload(
|
||||
courseId: string,
|
||||
exerciseId: string,
|
||||
questionId: number | string,
|
||||
fields: TExerciseVideoRecordingUploadInit
|
||||
) {
|
||||
const result = ZExerciseVideoRecordingUploadInit.safeParse(fields);
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'exercise');
|
||||
return null;
|
||||
}
|
||||
|
||||
return await this.execute<VideoRecordingUploadInitRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].exercise[':exerciseId'].question[':questionId'][
|
||||
'video-recording'
|
||||
].upload.init.$post({
|
||||
param: { courseId, exerciseId, questionId: String(questionId) },
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'initializing video recording upload'
|
||||
});
|
||||
}
|
||||
|
||||
async completeVideoRecordingUpload(
|
||||
courseId: string,
|
||||
exerciseId: string,
|
||||
questionId: number | string,
|
||||
fields: TExerciseVideoRecordingUploadComplete
|
||||
) {
|
||||
const result = ZExerciseVideoRecordingUploadComplete.safeParse(fields);
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'exercise');
|
||||
return null;
|
||||
}
|
||||
|
||||
return await this.execute<VideoRecordingUploadCompleteRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].exercise[':exerciseId'].question[':questionId'][
|
||||
'video-recording'
|
||||
].upload.complete.$post({
|
||||
param: { courseId, exerciseId, questionId: String(questionId) },
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'completing video recording upload'
|
||||
});
|
||||
}
|
||||
|
||||
async getVideoRecordingPlaybackUrl(
|
||||
courseId: string,
|
||||
exerciseId: string,
|
||||
submissionId: string,
|
||||
questionId: number | string
|
||||
) {
|
||||
return await this.execute<VideoRecordingPlaybackRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].exercise[':exerciseId'].submission[':submissionId'].question[':questionId'][
|
||||
'video-recording'
|
||||
].playback.$get({
|
||||
param: { courseId, exerciseId, submissionId, questionId: String(questionId) }
|
||||
}),
|
||||
logContext: 'getting video recording playback URL'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an exercise from a template
|
||||
* @param courseId Course ID
|
||||
@@ -220,7 +300,7 @@ export class ExerciseApi extends BaseApiWithErrors {
|
||||
logContext: 'creating exercise from template',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.exercise = response.data as Exercise;
|
||||
this.exercise = response.data as unknown as Exercise;
|
||||
snackbar.success('Exercise created successfully');
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
|
||||
@@ -163,9 +163,20 @@ export class LessonApi extends BaseApiWithErrors {
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'updating lesson',
|
||||
onSuccess: () => {
|
||||
this.errors = {};
|
||||
},
|
||||
onError: (result) => {
|
||||
console.log('onError', result);
|
||||
snackbar.error('snackbar.lessons.lesson_update_failed');
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('snackbar.lessons.lesson_update_failed');
|
||||
return;
|
||||
}
|
||||
if ('error' in result && 'field' in result && result.field) {
|
||||
this.errors[result.field] = result.error;
|
||||
snackbar.error(result.error);
|
||||
} else {
|
||||
snackbar.error('snackbar.lessons.lesson_update_failed');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
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 CopyIcon from '@lucide/svelte/icons/copy';
|
||||
import { copyCourseModal, deleteCourseModal } from '$features/course/utils/store';
|
||||
import { copyPublicCoursePageUrl, openCoursePreview } from '$features/course/utils/course-preview';
|
||||
import { currentOrgDomain } from '$lib/utils/store/org';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
@@ -14,9 +17,23 @@
|
||||
title: string;
|
||||
description: string;
|
||||
isPublished?: boolean;
|
||||
courseType?: string | null;
|
||||
slug?: string;
|
||||
/** Compact menu for LMS course cards */
|
||||
lmsPublicQuickOnly?: boolean;
|
||||
}
|
||||
|
||||
let { id, title, description, isPublished = false }: Props = $props();
|
||||
let {
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
isPublished = false,
|
||||
courseType = null,
|
||||
slug = '',
|
||||
lmsPublicQuickOnly = false
|
||||
}: Props = $props();
|
||||
|
||||
const showPublicCourseLinks = $derived(isPublished && courseType === 'PUBLIC' && slug.trim().length > 0);
|
||||
|
||||
function redirect(url: string) {
|
||||
goto(resolve(url, {}));
|
||||
@@ -43,40 +60,65 @@
|
||||
$copyCourseModal.description = description;
|
||||
}
|
||||
|
||||
function handleOpenCourseSite() {
|
||||
window.open(`/courses/${id}`, '_blank');
|
||||
function handleOpenPublicCourse() {
|
||||
openCoursePreview({
|
||||
courseId: id,
|
||||
courseSlug: slug,
|
||||
currentOrgDomain: $currentOrgDomain
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCopyCourseUrl() {
|
||||
await copyPublicCoursePageUrl(slug, $currentOrgDomain);
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="ui:data-[state=open]:opacity-100 absolute top-6 right-6 z-40 flex items-center justify-center opacity-0 transition-all delay-150 duration-200 ease-in-out group-hover:opacity-100"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<IconButton variant="outline">
|
||||
<EllipsisVerticalIcon size={16} />
|
||||
</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>
|
||||
<DropdownMenu.Item onclick={handleShareCourse}>
|
||||
{$t('courses.course_card.context_menu.share')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleInvite}>
|
||||
{$t('courses.course_card.context_menu.invite')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item class="text-red-600" onclick={handleDeleteCourse}>
|
||||
{$t('courses.course_card.context_menu.delete')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{#if lmsPublicQuickOnly ? showPublicCourseLinks : true}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="ui:data-[state=open]:opacity-100 absolute top-6 right-6 z-40 flex items-center justify-center opacity-0 transition-all delay-150 duration-200 ease-in-out group-hover:opacity-100"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<IconButton variant="outline">
|
||||
<EllipsisVerticalIcon size={16} />
|
||||
</IconButton>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
{#if lmsPublicQuickOnly}
|
||||
<DropdownMenu.Item onclick={handleOpenPublicCourse}>
|
||||
<ExternalLinkIcon size={16} class="mr-2" />
|
||||
{$t('courses.course_card.context_menu.open_public_course')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={() => void handleCopyCourseUrl()}>
|
||||
<CopyIcon size={16} class="mr-2" />
|
||||
{$t('courses.course_card.context_menu.copy_course_url')}
|
||||
</DropdownMenu.Item>
|
||||
{:else}
|
||||
{#if showPublicCourseLinks}
|
||||
<DropdownMenu.Item onclick={handleOpenPublicCourse}>
|
||||
<ExternalLinkIcon size={16} class="mr-2" />
|
||||
{$t('courses.course_card.context_menu.open_public_course')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={() => void handleCopyCourseUrl()}>
|
||||
<CopyIcon size={16} class="mr-2" />
|
||||
{$t('courses.course_card.context_menu.copy_course_url')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
{/if}
|
||||
<DropdownMenu.Item onclick={handleCloneCourse}>
|
||||
{$t('courses.course_card.context_menu.clone')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleShareCourse}>
|
||||
{$t('courses.course_card.context_menu.share')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleInvite}>
|
||||
{$t('courses.course_card.context_menu.invite')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item class="text-red-600" onclick={handleDeleteCourse}>
|
||||
{$t('courses.course_card.context_menu.delete')}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/if}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
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 GlobeIcon from '@lucide/svelte/icons/globe';
|
||||
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
|
||||
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
@@ -165,6 +166,18 @@
|
||||
: undefined
|
||||
);
|
||||
|
||||
const visibilityBadge = $derived(
|
||||
type === 'PUBLIC'
|
||||
? {
|
||||
label: $t('courses.course_card.public_badge'),
|
||||
icon: GlobeIcon,
|
||||
iconClass: 'custom ui:text-primary size-3.5 shrink-0'
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
|
||||
const showLmsPublicCourseMenu = $derived(isLMS && type === 'PUBLIC' && !!slug?.trim() && isPublished);
|
||||
|
||||
const complianceStatusKey = $derived(
|
||||
isLMS && type === 'COMPLIANCE' && !isExplore
|
||||
? getStudentCourseComplianceStatusKey(course as UserEnrolledCourses[number])
|
||||
@@ -202,6 +215,7 @@
|
||||
{title}
|
||||
{description}
|
||||
{typeBadge}
|
||||
{visibilityBadge}
|
||||
class="group relative"
|
||||
>
|
||||
{#snippet media()}
|
||||
@@ -211,8 +225,12 @@
|
||||
{#snippet overlay()}
|
||||
{#if actions}
|
||||
{@render actions()}
|
||||
{:else if !isLMS && !isOnLandingPage}
|
||||
<CardDropdown {id} {title} {description} {isPublished} />
|
||||
{:else if !isOnLandingPage}
|
||||
{#if !isLMS}
|
||||
<CardDropdown {id} {title} {description} {isPublished} courseType={type} {slug} />
|
||||
{:else if showLmsPublicCourseMenu}
|
||||
<CardDropdown {id} {title} {description} {isPublished} courseType={type} {slug} lmsPublicQuickOnly={true} />
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
|
||||
@@ -16,11 +16,12 @@
|
||||
import { openCoursePreview } from '$features/course/utils/course-preview';
|
||||
import { toggleAiAssistant } from '$features/ai-assistant/utils/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
import CoursePublishBadge from './course-publish-badge.svelte';
|
||||
import CoursePublicBadge from './course-public-badge.svelte';
|
||||
|
||||
const siteName = $derived($currentOrg.siteName);
|
||||
const showCoursePublishBadge = $derived(!$isStudentExperience);
|
||||
const isPublicCourse = $derived(courseApi.course?.type === 'PUBLIC');
|
||||
|
||||
$effect(() => {
|
||||
if (!siteName) return;
|
||||
@@ -38,7 +39,9 @@
|
||||
</script>
|
||||
|
||||
<header
|
||||
class="border-border ui:bg-background sticky top-0 z-100 flex h-12 w-full shrink-0 items-center gap-2 border-b backdrop-blur transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-8"
|
||||
class="border-border ui:bg-background sticky top-0 z-100 flex w-full shrink-0 items-center gap-2 border-b backdrop-blur transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-8 {isPublicCourse
|
||||
? 'h-auto min-h-14 py-1.5'
|
||||
: 'h-12'}"
|
||||
>
|
||||
<div class="flex w-full items-center gap-2 px-4">
|
||||
<Sidebar.Trigger />
|
||||
@@ -47,24 +50,34 @@
|
||||
<Separator orientation="vertical" />
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<p class="max-w-2xs truncate text-sm font-medium">
|
||||
{courseApi.course?.title || ''}
|
||||
</p>
|
||||
<div class="flex w-[60%] min-w-0 flex-1 flex-col justify-center gap-0.5">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<p class="max-w-xs truncate text-sm font-medium">
|
||||
{courseApi.course?.title || ''}
|
||||
</p>
|
||||
|
||||
{#if showCoursePublishBadge}
|
||||
<CoursePublishBadge isPublished={courseApi.course?.isPublished} />
|
||||
{#if isPublicCourse}
|
||||
<CoursePublicBadge class="shrink-0" />
|
||||
{/if}
|
||||
|
||||
{#if showCoursePublishBadge}
|
||||
<CoursePublishBadge isPublished={courseApi.course?.isPublished ?? false} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isPublicCourse}
|
||||
<p class="ui:text-muted-foreground max-w-xl truncate text-xs">
|
||||
{$t('course.header.public_course_helper')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<span class="grow"></span>
|
||||
|
||||
{#if !$isFreePlan}
|
||||
<Button variant="outline" size="sm" onclick={toggleAiAssistant}>
|
||||
<SparklesIcon size={14} />
|
||||
{$t('course.navItems.nav_ai_assistant')}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="outline" size="sm" onclick={toggleAiAssistant}>
|
||||
<SparklesIcon size={14} />
|
||||
{$t('course.navItems.nav_ai_assistant')}
|
||||
</Button>
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
|
||||
@@ -11,8 +11,13 @@
|
||||
import { Image } from '$features/ui';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { copyCourseModal, deleteCourseModal } from '$features/course/utils/store';
|
||||
import { copyPublicCoursePageUrl, openCoursePreview } from '$features/course/utils/course-preview';
|
||||
import { currentOrgDomain } from '$lib/utils/store/org';
|
||||
import { buildCoursePlaceholderAvatarUrl } from '$features/course/utils/course-list-row-utils';
|
||||
import CourseContentIcon from './course-content-icon.svelte';
|
||||
import CoursePublicBadge from './course-public-badge.svelte';
|
||||
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
|
||||
import CopyIcon from '@lucide/svelte/icons/copy';
|
||||
|
||||
interface Tag {
|
||||
id: string;
|
||||
@@ -59,10 +64,14 @@
|
||||
|
||||
const bannerImage = $derived(logo?.trim() ? logo : '/images/classroomio-course-img-template.jpg');
|
||||
|
||||
const showPublicCourseLinks = $derived(isPublished && type === 'PUBLIC' && slug.trim().length > 0);
|
||||
|
||||
const showActionsColumn = $derived(!isLMS || (isLMS && showPublicCourseLinks));
|
||||
|
||||
const gridTemplateColumns = $derived(
|
||||
isLMS
|
||||
? '7rem minmax(0, 2fr) 5.5rem minmax(0, 2fr) 4.5rem 6rem'
|
||||
: '7rem minmax(0, 2fr) 5.5rem minmax(0, 2fr) 4.5rem 6rem 2.5rem'
|
||||
showActionsColumn
|
||||
? '7rem minmax(0, 2fr) 5.5rem minmax(0, 2fr) 4.5rem 6rem 2.5rem'
|
||||
: '7rem minmax(0, 2fr) 5.5rem minmax(0, 2fr) 4.5rem 6rem'
|
||||
);
|
||||
|
||||
const studentPlaceholderAvatarUrls = $derived.by(() => [
|
||||
@@ -112,6 +121,10 @@
|
||||
$deleteCourseModal.id = id;
|
||||
$deleteCourseModal.title = title;
|
||||
}
|
||||
|
||||
async function handleCopyCourseUrl() {
|
||||
await copyPublicCoursePageUrl(slug, $currentOrgDomain);
|
||||
}
|
||||
</script>
|
||||
|
||||
<ResourceListRow.Root
|
||||
@@ -133,7 +146,12 @@
|
||||
|
||||
<!-- Title / type / updated -->
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<p class="line-clamp-2 text-base">{title}</p>
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<p class="line-clamp-2 min-w-0 flex-1 text-base">{title}</p>
|
||||
{#if type === 'PUBLIC'}
|
||||
<CoursePublicBadge class="shrink-0" />
|
||||
{/if}
|
||||
</div>
|
||||
{#if typeLabel}
|
||||
<p class="ui:text-muted-foreground mt-0.5 text-sm">{typeLabel}</p>
|
||||
{/if}
|
||||
@@ -225,7 +243,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
{#if !isLMS}
|
||||
{#if showActionsColumn}
|
||||
<div class="flex justify-end">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
@@ -235,7 +253,7 @@
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-8"
|
||||
aria-label="Course actions"
|
||||
aria-label={$t('courses.course_card.actions_menu_aria')}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EllipsisVerticalIcon class="size-4" />
|
||||
@@ -243,16 +261,38 @@
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={handleOpen}>
|
||||
{$t('courses.course_card.context_menu.open')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleClone}>
|
||||
{$t('courses.course_card.context_menu.clone')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item variant="destructive" onclick={handleDelete}>
|
||||
{$t('courses.course_card.context_menu.delete')}
|
||||
</DropdownMenu.Item>
|
||||
{#if showPublicCourseLinks}
|
||||
<DropdownMenu.Item
|
||||
onclick={() =>
|
||||
openCoursePreview({
|
||||
courseId: id,
|
||||
courseSlug: slug,
|
||||
currentOrgDomain: $currentOrgDomain
|
||||
})}
|
||||
>
|
||||
<ExternalLinkIcon class="mr-2 size-4" />
|
||||
{$t('courses.course_card.context_menu.open_public_course')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={() => void handleCopyCourseUrl()}>
|
||||
<CopyIcon class="mr-2 size-4" />
|
||||
{$t('courses.course_card.context_menu.copy_course_url')}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
{#if !isLMS}
|
||||
{#if showPublicCourseLinks}
|
||||
<DropdownMenu.Separator />
|
||||
{/if}
|
||||
<DropdownMenu.Item onclick={handleOpen}>
|
||||
{$t('courses.course_card.context_menu.open')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleClone}>
|
||||
{$t('courses.course_card.context_menu.clone')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item variant="destructive" onclick={handleDelete}>
|
||||
{$t('courses.course_card.context_menu.delete')}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import GlobeIcon from '@lucide/svelte/icons/globe';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { class: className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="ui:inline-flex ui:shrink-0 ui:items-center ui:gap-1 ui:rounded-md! ui:text-xs ui:capitalize {className}"
|
||||
>
|
||||
<GlobeIcon class="size-3.5 shrink-0" />
|
||||
{$t('courses.course_card.public_badge')}
|
||||
</Badge>
|
||||
+12
-7
@@ -4,18 +4,23 @@
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { deleteConfirmation } from './store';
|
||||
|
||||
let { onDelete = () => {}, onCancel = () => {} } = $props();
|
||||
let {
|
||||
title = $t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.title'),
|
||||
description = $t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.sure'),
|
||||
cancelLabel = $t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.no'),
|
||||
confirmLabel = $t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.yes'),
|
||||
onDelete = () => {},
|
||||
onCancel = () => {}
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open={$deleteConfirmation.open}>
|
||||
<Dialog.Content class="w-96">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.title')}</Dialog.Title>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div>
|
||||
<p class="mt-0 text-base dark:text-white">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.sure')}
|
||||
</p>
|
||||
<p class="mt-0 text-base dark:text-white">{description}</p>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button
|
||||
@@ -25,7 +30,7 @@
|
||||
onCancel();
|
||||
}}
|
||||
>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.no')}
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
onclick={() => {
|
||||
@@ -33,7 +38,7 @@
|
||||
$deleteConfirmation.open = false;
|
||||
}}
|
||||
>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.yes')}
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
<script lang="ts">
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||
import GripVerticalIcon from '@lucide/svelte/icons/grip-vertical';
|
||||
import { dndzone } from 'svelte-dnd-action';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { tick } from 'svelte';
|
||||
|
||||
import {
|
||||
questionnaire,
|
||||
deleteConfirmation,
|
||||
questionnaireValidation,
|
||||
handleRemoveQuestion,
|
||||
handleCode
|
||||
handleCode,
|
||||
handleAddQuestion,
|
||||
handleRemoveSection,
|
||||
handleReorderSections,
|
||||
handleReorderQuestionsInSection,
|
||||
handleUpdateSectionAfterBehavior,
|
||||
handleUpdateSectionColorTheme,
|
||||
handleUpdateSectionDescription,
|
||||
handleUpdateSectionTitle,
|
||||
hasSections,
|
||||
getQuestionsForSection
|
||||
} from './store';
|
||||
import { preventDefault } from '$lib/utils/functions/svelte';
|
||||
import { exerciseApi } from '$features/course/api';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import { PREMIUM_QUESTION_TYPE_KEYS, QUESTION_TYPES } from '$features/ui/question/constants';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { QUESTION_TYPES } from '$features/ui/question/constants';
|
||||
import { ExerciseQuestion } from '@cio/ui';
|
||||
|
||||
import OrderModal from './order-modal.svelte';
|
||||
import QuestionTypeSelect from './question-type-select.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
@@ -26,17 +34,20 @@
|
||||
import { QuestionContainer } from '$features/course/components';
|
||||
import { uploadImage } from '$lib/utils/services/upload';
|
||||
import {
|
||||
getExerciseEditorQuestionTypeLabel,
|
||||
getQuestionTypeOptionById,
|
||||
questionTypeSupportsOptions,
|
||||
toExerciseQuestionModel
|
||||
} from './question-type-utils';
|
||||
import { QUESTION_TYPE_KEY } from '@cio/question-types';
|
||||
import { getExerciseQuestionLabels } from './question-labels';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
import { openUpgradeModal } from '$lib/utils/functions/org';
|
||||
import { PremiumIcon } from '@cio/ui/custom/moving-icons';
|
||||
import SectionEditor from './section-editor.svelte';
|
||||
import type { Question } from '$features/course/types';
|
||||
import type { ExerciseEditorErrors } from './store';
|
||||
|
||||
const flipDurationMs = 200;
|
||||
const initialQuestionsLength = $questionnaire.questions.length;
|
||||
type QuestionDndEvent = CustomEvent<{ items: Question[] }>;
|
||||
|
||||
interface Props {
|
||||
shouldDeleteExercise?: boolean;
|
||||
@@ -45,19 +56,52 @@
|
||||
/** Self-paced + all auto-gradable question types: enforce points > 0 */
|
||||
requiresPositivePointsForAutoGrade?: boolean;
|
||||
selfPacedCourse?: boolean;
|
||||
/** Public course restricts picker to auto-gradable question types only. */
|
||||
isPublicCourse?: boolean;
|
||||
reorderQuestions?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
shouldDeleteExercise = $bindable(false),
|
||||
exerciseId,
|
||||
goBack = () => {},
|
||||
exerciseId: _exerciseId,
|
||||
goBack: _goBack = () => {},
|
||||
requiresPositivePointsForAutoGrade = false,
|
||||
selfPacedCourse = false
|
||||
selfPacedCourse = false,
|
||||
isPublicCourse = false,
|
||||
reorderQuestions = false
|
||||
}: Props = $props();
|
||||
|
||||
const availableQuestionTypes = $derived(
|
||||
isPublicCourse ? QUESTION_TYPES.filter((type) => type.autoGradable) : QUESTION_TYPES
|
||||
);
|
||||
|
||||
let questionIdToDelete = $state(null);
|
||||
let sectionIdToDelete = $state<string | null>(null);
|
||||
let deleteConfirmationType = $state<'question' | 'section'>('question');
|
||||
let reorderQuestionItemsBySectionId = $state<Record<string, Question[]>>({});
|
||||
let previousReorderQuestions = $state(false);
|
||||
let errors = $derived($questionnaireValidation);
|
||||
const questionLabels = $derived(getExerciseQuestionLabels());
|
||||
const activeSections = $derived(
|
||||
[...($questionnaire.sections ?? [])].filter((section) => !section.deletedAt).sort((a, b) => a.order - b.order)
|
||||
);
|
||||
const sectionLabels = $derived({
|
||||
questions: t.get('course.navItem.lessons.exercises.all_exercises.section.questions'),
|
||||
shortQuestions: t.get('course.navItem.lessons.exercises.all_exercises.section.short_questions'),
|
||||
required: t.get('course.navItem.lessons.exercises.all_exercises.section.required'),
|
||||
addQuestion: t.get('course.navItem.lessons.exercises.all_exercises.section.add_question'),
|
||||
delete: t.get('course.navItem.lessons.exercises.all_exercises.section.delete'),
|
||||
moveUp: t.get('course.navItem.lessons.exercises.all_exercises.section.move_up'),
|
||||
moveDown: t.get('course.navItem.lessons.exercises.all_exercises.section.move_down'),
|
||||
color: t.get('course.navItem.lessons.exercises.all_exercises.section.color'),
|
||||
descriptionPlaceholder: t.get('course.navItem.lessons.exercises.all_exercises.section.description_placeholder')
|
||||
});
|
||||
const sectionAfterBehaviorLabels = $derived({
|
||||
afterSectionPrefix: t.get('course.navItem.lessons.exercises.all_exercises.section.after_section'),
|
||||
continue: t.get('course.navItem.lessons.exercises.all_exercises.section.continue'),
|
||||
goToSection: t.get('course.navItem.lessons.exercises.all_exercises.section.go_to_section'),
|
||||
submit: t.get('course.navItem.lessons.exercises.all_exercises.section.submit')
|
||||
});
|
||||
|
||||
function shouldScrollToLast(questionId, questions) {
|
||||
const [lastQuestion] = questions.slice(-1);
|
||||
@@ -67,25 +111,62 @@
|
||||
return isLast && initialQuestionsLength !== currentQuestionsLength;
|
||||
}
|
||||
|
||||
async function scrollToExerciseElement(elementId: string) {
|
||||
await tick();
|
||||
requestAnimationFrame(() => {
|
||||
document.getElementById(elementId)?.scrollIntoView({
|
||||
block: 'start',
|
||||
behavior: 'smooth',
|
||||
inline: 'nearest'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getQuestionElementId(questionId: string | number) {
|
||||
return `exercise-question-${questionId}`;
|
||||
}
|
||||
|
||||
async function addQuestionToSection(sectionId: string) {
|
||||
const questionId = handleAddQuestion(sectionId);
|
||||
await scrollToExerciseElement(getQuestionElementId(questionId));
|
||||
}
|
||||
|
||||
function onInitDeleteClicked(questionId) {
|
||||
return () => {
|
||||
deleteConfirmationType = 'question';
|
||||
questionIdToDelete = questionId;
|
||||
$deleteConfirmation.open = true;
|
||||
};
|
||||
}
|
||||
|
||||
function onFinalDeleteClicked() {
|
||||
if (deleteConfirmationType === 'section' && sectionIdToDelete) {
|
||||
deleteSection(sectionIdToDelete);
|
||||
sectionIdToDelete = null;
|
||||
deleteConfirmationType = 'question';
|
||||
return;
|
||||
}
|
||||
|
||||
handleRemoveQuestion(questionIdToDelete)();
|
||||
questionIdToDelete = null;
|
||||
}
|
||||
|
||||
function getQuestionErrorMsg(errors, question, errorKey) {
|
||||
function onCancelDeleteClicked() {
|
||||
questionIdToDelete = null;
|
||||
sectionIdToDelete = null;
|
||||
deleteConfirmationType = 'question';
|
||||
}
|
||||
|
||||
function getQuestionErrorMsg(
|
||||
errors: ExerciseEditorErrors,
|
||||
question: Question,
|
||||
errorKey: 'option' | 'title' | 'points'
|
||||
) {
|
||||
return errors[question.id] ? errors[question.id][errorKey] : null;
|
||||
}
|
||||
|
||||
function getQuestionTypeLabel(questionType) {
|
||||
if (!questionType) return t.get('course.navItem.lessons.exercises.all_exercises.edit_mode.select_type');
|
||||
if (questionType.labelKey) return t.get(questionType.labelKey);
|
||||
return questionType.label;
|
||||
function getSectionErrorMsg(errors: ExerciseEditorErrors, sectionId: string, errorKey: 'title' | 'description') {
|
||||
return errors[sectionId]?.[errorKey];
|
||||
}
|
||||
|
||||
function makeDefaultOption() {
|
||||
@@ -117,6 +198,7 @@
|
||||
|
||||
const current = q.questions[idx];
|
||||
let nextOptions = current.options ?? [];
|
||||
let nextSettings = current.settings ?? {};
|
||||
|
||||
if (nextQuestionTypeKey === QUESTION_TYPE_KEY.TRUE_FALSE) {
|
||||
const correctValue = (current.settings as { correctValue?: boolean })?.correctValue ?? true;
|
||||
@@ -130,11 +212,20 @@
|
||||
nextOptions = [];
|
||||
}
|
||||
|
||||
if (nextQuestionTypeKey === QUESTION_TYPE_KEY.VIDEO_RECORDING) {
|
||||
nextSettings = {
|
||||
...nextSettings,
|
||||
maxDurationSeconds: 60,
|
||||
allowRetakes: true
|
||||
};
|
||||
}
|
||||
|
||||
const next = [...q.questions];
|
||||
next[idx] = {
|
||||
...current,
|
||||
questionType: nextQuestionType,
|
||||
questionTypeId: nextQuestionType.id,
|
||||
settings: nextSettings,
|
||||
options: nextOptions,
|
||||
isDirty: true
|
||||
};
|
||||
@@ -179,12 +270,158 @@
|
||||
return { ...q, questions: next };
|
||||
});
|
||||
}
|
||||
|
||||
function moveSection(sectionId: string, direction: -1 | 1) {
|
||||
const currentIndex = activeSections.findIndex((section) => section.id === sectionId);
|
||||
const targetIndex = currentIndex + direction;
|
||||
if (currentIndex < 0 || targetIndex < 0 || targetIndex >= activeSections.length) return;
|
||||
|
||||
const orderedIds = activeSections.map((section) => section.id);
|
||||
const [sectionToMove] = orderedIds.splice(currentIndex, 1);
|
||||
orderedIds.splice(targetIndex, 0, sectionToMove);
|
||||
handleReorderSections(orderedIds);
|
||||
}
|
||||
|
||||
function deleteSection(sectionId: string) {
|
||||
const sectionIndex = activeSections.findIndex((section) => section.id === sectionId);
|
||||
const nearestSection = activeSections[sectionIndex + 1] ?? activeSections[sectionIndex - 1];
|
||||
handleRemoveSection(sectionId, nearestSection?.id);
|
||||
}
|
||||
|
||||
function onInitSectionDeleteClicked(sectionId: string) {
|
||||
deleteConfirmationType = 'section';
|
||||
sectionIdToDelete = sectionId;
|
||||
$deleteConfirmation.open = true;
|
||||
}
|
||||
|
||||
function syncReorderQuestionItems() {
|
||||
reorderQuestionItemsBySectionId = Object.fromEntries(
|
||||
activeSections.map((section) => [section.id, getQuestionsForSection($questionnaire.questions, section.id)])
|
||||
);
|
||||
}
|
||||
|
||||
function getReorderQuestionsForSection(sectionId: string, sectionQuestions: Question[]) {
|
||||
return reorderQuestionItemsBySectionId[sectionId] ?? sectionQuestions;
|
||||
}
|
||||
|
||||
function handleSectionQuestionDndConsider(sectionId: string, event: QuestionDndEvent) {
|
||||
reorderQuestionItemsBySectionId = {
|
||||
...reorderQuestionItemsBySectionId,
|
||||
[sectionId]: event.detail.items
|
||||
};
|
||||
}
|
||||
|
||||
function handleSectionQuestionDndFinalize(sectionId: string, event: QuestionDndEvent) {
|
||||
reorderQuestionItemsBySectionId = {
|
||||
...reorderQuestionItemsBySectionId,
|
||||
[sectionId]: event.detail.items
|
||||
};
|
||||
handleReorderQuestionsInSection(
|
||||
sectionId,
|
||||
event.detail.items.map((question) => question.id)
|
||||
);
|
||||
}
|
||||
|
||||
function getRequiredQuestionCount(questions: Question[]) {
|
||||
return questions.filter((question) => toExerciseQuestionModel(question).required !== false).length;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (reorderQuestions && !previousReorderQuestions) {
|
||||
syncReorderQuestionItems();
|
||||
}
|
||||
|
||||
if (!reorderQuestions && previousReorderQuestions) {
|
||||
reorderQuestionItemsBySectionId = {};
|
||||
}
|
||||
|
||||
previousReorderQuestions = reorderQuestions;
|
||||
});
|
||||
</script>
|
||||
|
||||
<DeleteConfirmationModal onCancel={() => (questionIdToDelete = null)} onDelete={onFinalDeleteClicked} />
|
||||
<DeleteConfirmationModal
|
||||
title={deleteConfirmationType === 'section'
|
||||
? $t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.section_title')
|
||||
: $t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.title')}
|
||||
description={deleteConfirmationType === 'section'
|
||||
? $t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.section_sure')
|
||||
: $t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.sure')}
|
||||
onCancel={onCancelDeleteClicked}
|
||||
onDelete={onFinalDeleteClicked}
|
||||
/>
|
||||
|
||||
<OrderModal />
|
||||
|
||||
{#snippet questionEditor(question, index)}
|
||||
<QuestionContainer
|
||||
elementId={getQuestionElementId(question.id)}
|
||||
key={String(question.id ?? `new-${index}`)}
|
||||
onClose={onInitDeleteClicked(question.id)}
|
||||
scrollToQuestion={shouldScrollToLast(question.id, $questionnaire.questions)}
|
||||
bind:points={question.points}
|
||||
hasError={!!errors[question.id]}
|
||||
errorMsg={getQuestionErrorMsg(errors, question, 'points')}
|
||||
pointsHint={requiresPositivePointsForAutoGrade && Number(question.points) === 0
|
||||
? $t('course.navItem.lessons.exercises.all_exercises.points_required_auto_grade')
|
||||
: null}
|
||||
onPointsChange={() => {
|
||||
question.isDirty = true;
|
||||
}}
|
||||
>
|
||||
{#if typeof question.code === 'string'}
|
||||
<div class="my-3 flex w-3/5 items-center justify-between">
|
||||
<TextareaField
|
||||
bind:value={question.code}
|
||||
rows={2}
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.write')}
|
||||
/>
|
||||
<IconButton onclick={() => handleCode(question.id, false)}>
|
||||
<TrashIcon size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-2 flex flex-col">
|
||||
<ExerciseQuestion.QuestionRenderer
|
||||
showContainer={false}
|
||||
contract={{
|
||||
mode: 'edit',
|
||||
question: toExerciseQuestionModel(question),
|
||||
labels: questionLabels,
|
||||
onImageUpload: uploadImage
|
||||
}}
|
||||
onQuestionChange={(nextQuestion) => onSharedQuestionChange(question.id, nextQuestion)}
|
||||
>
|
||||
{#snippet questionTypeSelect()}
|
||||
<QuestionTypeSelect
|
||||
value={question.questionTypeId?.toString()}
|
||||
onValueChange={(nextValue) => onQuestionTypeChange(question.id, nextValue)}
|
||||
triggerQuestionType={question?.questionType}
|
||||
types={availableQuestionTypes}
|
||||
{selfPacedCourse}
|
||||
/>
|
||||
{/snippet}
|
||||
</ExerciseQuestion.QuestionRenderer>
|
||||
|
||||
{#if getQuestionErrorMsg(errors, question, 'option')}
|
||||
<p class="text-sm text-red-500">{getQuestionErrorMsg(errors, question, 'option')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</QuestionContainer>
|
||||
{/snippet}
|
||||
|
||||
{#snippet compactQuestionRow(question, index)}
|
||||
<div class="border-border flex items-center gap-3 rounded-md border bg-white px-4 py-3 dark:bg-black">
|
||||
<span class="ui:text-muted-foreground w-6 text-sm font-medium tabular-nums">{index + 1}</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-semibold">
|
||||
{question.title || getExerciseEditorQuestionTypeLabel(question?.questionType)}
|
||||
</p>
|
||||
<p class="ui:text-muted-foreground text-xs">{getExerciseEditorQuestionTypeLabel(question?.questionType)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="mb-20 w-full">
|
||||
{#if Object.values(errors).length}
|
||||
<div class="mb-4 flex w-full justify-center">
|
||||
@@ -192,107 +429,92 @@
|
||||
</div>
|
||||
{/if}
|
||||
<div class="questions px-6 pt-6">
|
||||
{#each $questionnaire.questions as question, index (question.id)}
|
||||
{#if !question.deletedAt}
|
||||
<QuestionContainer
|
||||
key={String(question.id ?? `new-${index}`)}
|
||||
onClose={onInitDeleteClicked(question.id)}
|
||||
scrollToQuestion={shouldScrollToLast(question.id, $questionnaire.questions)}
|
||||
bind:points={question.points}
|
||||
hasError={!!errors[question.id]}
|
||||
errorMsg={getQuestionErrorMsg(errors, question, 'points')}
|
||||
pointsHint={requiresPositivePointsForAutoGrade && Number(question.points) === 0
|
||||
? $t('course.navItem.lessons.exercises.all_exercises.points_required_auto_grade')
|
||||
: null}
|
||||
onPointsChange={() => {
|
||||
question.isDirty = true;
|
||||
}}
|
||||
>
|
||||
{#if typeof question.code === 'string'}
|
||||
<div class="my-3 flex w-3/5 items-center justify-between">
|
||||
<TextareaField
|
||||
bind:value={question.code}
|
||||
rows={2}
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.write')}
|
||||
/>
|
||||
<IconButton onclick={() => handleCode(question.id, false)}>
|
||||
<TrashIcon size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-2 flex flex-col">
|
||||
<ExerciseQuestion.QuestionRenderer
|
||||
showContainer={false}
|
||||
contract={{
|
||||
mode: 'edit',
|
||||
question: toExerciseQuestionModel(question),
|
||||
labels: questionLabels,
|
||||
onImageUpload: uploadImage
|
||||
{#if hasSections($questionnaire.sections)}
|
||||
<div class="space-y-5">
|
||||
{#each activeSections as section, sectionIndex (section.id)}
|
||||
{@const sectionQuestions = getQuestionsForSection($questionnaire.questions, section.id)}
|
||||
{@const reorderSectionQuestions = getReorderQuestionsForSection(section.id, sectionQuestions)}
|
||||
<SectionEditor
|
||||
exerciseSectionId={section.id}
|
||||
title={section.title}
|
||||
description={section.description}
|
||||
sectionNumber={sectionIndex + 1}
|
||||
totalSections={activeSections.length}
|
||||
colorTheme={section.colorTheme}
|
||||
questionCount={sectionQuestions.length}
|
||||
requiredQuestionCount={getRequiredQuestionCount(sectionQuestions)}
|
||||
labels={sectionLabels}
|
||||
titleError={getSectionErrorMsg(errors, section.id, 'title')}
|
||||
descriptionError={getSectionErrorMsg(errors, section.id, 'description')}
|
||||
showReorderHandles={reorderQuestions}
|
||||
onTitleChange={(title) => handleUpdateSectionTitle(section.id, title)}
|
||||
onDescriptionChange={(description) => handleUpdateSectionDescription(section.id, description)}
|
||||
onColorThemeChange={(colorTheme) => handleUpdateSectionColorTheme(section.id, colorTheme)}
|
||||
onAddQuestion={() => addQuestionToSection(section.id)}
|
||||
onDelete={() => onInitSectionDeleteClicked(section.id)}
|
||||
onMoveUp={() => moveSection(section.id, -1)}
|
||||
onMoveDown={() => moveSection(section.id, 1)}
|
||||
canMoveUp={sectionIndex > 0}
|
||||
canMoveDown={sectionIndex < activeSections.length - 1}
|
||||
>
|
||||
<div
|
||||
class="space-y-2"
|
||||
use:dndzone={{
|
||||
items: reorderQuestions ? reorderSectionQuestions : sectionQuestions,
|
||||
flipDurationMs,
|
||||
dropTargetStyle: {
|
||||
border: '2px #1d4ed8 solid',
|
||||
'border-style': 'dashed',
|
||||
'border-radius': '0.5rem'
|
||||
},
|
||||
dragDisabled: !reorderQuestions,
|
||||
type: `exercise-section-question-${section.id}`
|
||||
}}
|
||||
onQuestionChange={(nextQuestion) => onSharedQuestionChange(question.id, nextQuestion)}
|
||||
onconsider={(event) => handleSectionQuestionDndConsider(section.id, event)}
|
||||
onfinalize={(event) => handleSectionQuestionDndFinalize(section.id, event)}
|
||||
>
|
||||
{#snippet questionTypeSelect()}
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={question.questionTypeId?.toString()}
|
||||
onValueChange={(value) => onQuestionTypeChange(question.id, value)}
|
||||
{#each reorderQuestions ? reorderSectionQuestions : sectionQuestions as question, index (question.id)}
|
||||
<div
|
||||
animate:flip={{ duration: flipDurationMs }}
|
||||
class="flex items-start gap-3 transition {reorderQuestions
|
||||
? 'cursor-grab active:cursor-grabbing'
|
||||
: ''}"
|
||||
>
|
||||
<Select.Trigger class="w-[180px]">
|
||||
{getQuestionTypeLabel(question?.questionType)}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each QUESTION_TYPES as type (type.key)}
|
||||
{#if $isFreePlan && PREMIUM_QUESTION_TYPE_KEYS.has(type.key)}
|
||||
<button
|
||||
type="button"
|
||||
class="ui:flex ui:w-full ui:cursor-pointer ui:select-none ui:items-center ui:gap-2 ui:rounded-sm ui:py-1.5 ui:pl-2 ui:pr-8 ui:text-sm ui:outline-hidden ui:hover:bg-accent ui:hover:text-accent-foreground ui:relative"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openUpgradeModal();
|
||||
}}
|
||||
title={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.premium_question_type')}
|
||||
>
|
||||
<PremiumIcon size={16} class="ui:text-blue-700 ui:dark:text-white ui:shrink-0" />
|
||||
<span>{getQuestionTypeLabel(type)}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<Select.Item value={type.id.toString()} label={getQuestionTypeLabel(type)}>
|
||||
{#snippet children({ selected: _sel })}
|
||||
<span class="flex w-full min-w-0 items-center justify-between gap-2">
|
||||
<span class="truncate">{getQuestionTypeLabel(type)}</span>
|
||||
{#if selfPacedCourse}
|
||||
<Badge
|
||||
variant={type.autoGradable ? 'success' : 'warning'}
|
||||
class="ui:shrink-0 ui:text-[10px] ui:font-normal"
|
||||
>
|
||||
{type.autoGradable
|
||||
? $t(
|
||||
'course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_auto_gradable'
|
||||
)
|
||||
: $t(
|
||||
'course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_manual_grading'
|
||||
)}
|
||||
</Badge>
|
||||
{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
</Select.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{/snippet}
|
||||
</ExerciseQuestion.QuestionRenderer>
|
||||
<div class="ui:text-muted-foreground flex w-5 shrink-0 flex-col items-center gap-2 pt-3">
|
||||
{#if reorderQuestions}
|
||||
<GripVerticalIcon class="h-4 w-4 cursor-grab active:cursor-grabbing" aria-hidden="true" />
|
||||
{/if}
|
||||
<span class="text-xs font-medium tabular-nums">{index + 1}</span>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
{#if reorderQuestions}
|
||||
{@render compactQuestionRow(question, index)}
|
||||
{:else}
|
||||
{@render questionEditor(question, index)}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</SectionEditor>
|
||||
|
||||
{#if getQuestionErrorMsg(errors, question, 'option')}
|
||||
<p class="text-sm text-red-500">{getQuestionErrorMsg(errors, question, 'option')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</QuestionContainer>
|
||||
{/if}
|
||||
{/each}
|
||||
<ExerciseQuestion.SectionAfterBehavior
|
||||
{sectionIndex}
|
||||
currentSectionId={section.id}
|
||||
allSections={activeSections}
|
||||
afterBehavior={section.afterBehavior}
|
||||
labels={sectionAfterBehaviorLabels}
|
||||
onChange={(behavior) => handleUpdateSectionAfterBehavior(section.id, behavior)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
{#each $questionnaire.questions as question, index (question.id)}
|
||||
{#if !question.deletedAt}
|
||||
{@render questionEditor(question, index)}
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+26
-1
@@ -2,11 +2,13 @@
|
||||
import { Switch } from '@cio/ui/base/switch';
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import { questionnaire } from './store';
|
||||
import { exerciseApi } from '$features/course/api';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { slugifyTitle } from '@cio/utils/validation';
|
||||
|
||||
type Props = {
|
||||
exerciseId: string;
|
||||
@@ -15,13 +17,21 @@
|
||||
let { exerciseId }: Props = $props();
|
||||
|
||||
let saving = $state(false);
|
||||
let slug = $state($questionnaire.slug ?? '');
|
||||
|
||||
$effect(() => {
|
||||
slug = $questionnaire.slug ?? '';
|
||||
});
|
||||
|
||||
const isPublicCourse = $derived(courseApi.course?.type === 'PUBLIC');
|
||||
|
||||
async function saveSettings() {
|
||||
if (!courseApi.course?.id) return;
|
||||
saving = true;
|
||||
|
||||
await exerciseApi.update(courseApi.course.id, exerciseId, {
|
||||
allowMultipleAttempts: !!$questionnaire.allowMultipleAttempts
|
||||
allowMultipleAttempts: !!$questionnaire.allowMultipleAttempts,
|
||||
slug: isPublicCourse && slug ? slug : undefined
|
||||
});
|
||||
saving = false;
|
||||
|
||||
@@ -50,6 +60,21 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if isPublicCourse}
|
||||
<div class="rounded-lg border border-neutral-200 p-4 dark:border-neutral-700">
|
||||
<InputField
|
||||
label={$t('course.navItem.settings.slug.label')}
|
||||
helperMessage={$t('course.navItem.settings.slug.description')}
|
||||
value={slug}
|
||||
placeholder={slugifyTitle($questionnaire.title ?? '')}
|
||||
onInputChange={(e) => {
|
||||
slug = e.currentTarget.value;
|
||||
}}
|
||||
errorMessage={exerciseApi.errors.slug}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Button onclick={saveSettings} loading={saving} disabled={!courseApi.course?.id}>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.settings_save')}
|
||||
</Button>
|
||||
|
||||
@@ -158,6 +158,7 @@ export function transformQuestionsToApiFormat(
|
||||
question: q.title,
|
||||
points: q.points || 0,
|
||||
questionTypeId: q.questionTypeId,
|
||||
exerciseSectionId: q.exerciseSectionId ?? null,
|
||||
order: typeof (q as { order?: number }).order === 'number' ? (q as { order: number }).order : index + 1,
|
||||
settings: getQuestionSettings(q),
|
||||
options: questionTypeSupportsOptions(getQuestionTypeKey(q)) ? formatOptions(q.options) : [],
|
||||
|
||||
@@ -6,10 +6,14 @@
|
||||
import { toExerciseQuestionModel } from './question-type-utils';
|
||||
import { getExerciseQuestionLabels } from './question-labels';
|
||||
import { ExerciseQuestion } from '@cio/ui';
|
||||
import type { QuestionnaireMetaData } from './store';
|
||||
import { NumberBadge } from '@cio/ui/base/number-badge';
|
||||
import type { ExerciseSectionState, QuestionnaireMetaData } from './store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { getExerciseSectionDisplayTitle } from '$features/course/utils/exercise-section-utils';
|
||||
|
||||
interface Props {
|
||||
questions?: Question[];
|
||||
sections?: ExerciseSectionState[];
|
||||
questionnaireMetaData?: Partial<QuestionnaireMetaData>;
|
||||
/** Per-question scores (e.g. from `questionnaireMetaData.grades` or grading modal `questionAnswerByPoint`). */
|
||||
grades?: Record<string, number>;
|
||||
@@ -22,6 +26,7 @@
|
||||
|
||||
let {
|
||||
questions = [],
|
||||
sections = [],
|
||||
questionnaireMetaData: _questionnaireMetaData = {},
|
||||
grades = $bindable({}),
|
||||
disableGrading = true,
|
||||
@@ -46,6 +51,9 @@
|
||||
|
||||
const questionModels = $derived(questions.map((question) => toExerciseQuestionModel(question)));
|
||||
const questionLabels = $derived(getExerciseQuestionLabels());
|
||||
const sectionFallbackTitle = $derived($t('course.navItem.lessons.exercises.all_exercises.section.fallback_title'));
|
||||
const activeSections = $derived(sections.filter((section) => !section.deletedAt).sort((a, b) => a.order - b.order));
|
||||
const hasSectionGroups = $derived(activeSections.length > 0);
|
||||
const answersByKey = $derived(
|
||||
questionModels.reduce(
|
||||
(acc, questionModel, index) => {
|
||||
@@ -56,9 +64,55 @@
|
||||
{} as Record<string, AnswerData>
|
||||
)
|
||||
);
|
||||
|
||||
function getQuestionsForSection(exerciseSectionId: string) {
|
||||
return questions.filter((question) => !question.deletedAt && question.exerciseSectionId === exerciseSectionId);
|
||||
}
|
||||
|
||||
function getSectionPoints(sectionQuestions: Question[]) {
|
||||
return sectionQuestions.reduce((total, question) => total + Number(question.points ?? 0), 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if disableGrading && !Object.keys(grades || {}).length}
|
||||
{#if hasSectionGroups}
|
||||
<div class="space-y-6">
|
||||
{#each activeSections as section, sectionIndex (section.id)}
|
||||
{@const sectionQuestions = getQuestionsForSection(section.id)}
|
||||
{@const sectionQuestionModels = sectionQuestions.map((question) => toExerciseQuestionModel(question))}
|
||||
<section class="space-y-4 pt-4">
|
||||
<ExerciseQuestion.SectionHeader
|
||||
title={getExerciseSectionDisplayTitle({
|
||||
title: section.title,
|
||||
sectionNumber: sectionIndex + 1,
|
||||
sectionLabel: sectionFallbackTitle
|
||||
})}
|
||||
description={section.description}
|
||||
sectionNumber={sectionIndex + 1}
|
||||
totalSections={activeSections.length}
|
||||
colorTheme={section.colorTheme}
|
||||
questionCount={sectionQuestions.length}
|
||||
totalPoints={getSectionPoints(sectionQuestions)}
|
||||
labels={{
|
||||
section: $t('course.navItem.lessons.exercises.all_exercises.section.fallback_title'),
|
||||
questions: $t('course.navItem.lessons.exercises.all_exercises.view_mode.questions'),
|
||||
points: $t('course.navItem.lessons.exercises.all_exercises.view_mode.points')
|
||||
}}
|
||||
/>
|
||||
|
||||
<ExerciseQuestion.QuestionList
|
||||
contract={{
|
||||
mode: disableGrading ? 'review' : 'review',
|
||||
questions: sectionQuestionModels,
|
||||
answersByKey,
|
||||
labels: questionLabels,
|
||||
disabled: true
|
||||
}}
|
||||
itemClass="mb-4"
|
||||
/>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if disableGrading && !Object.keys(grades || {}).length}
|
||||
<ExerciseQuestion.QuestionList
|
||||
contract={{ mode: 'review', questions: questionModels, answersByKey, labels: questionLabels, disabled: true }}
|
||||
itemClass="mb-4"
|
||||
@@ -67,8 +121,8 @@
|
||||
{#each questions as currentQuestion, index (currentQuestion.id)}
|
||||
{@const questionModel = questionModels[index]}
|
||||
<div class="mb-4 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-base font-semibold text-gray-700 dark:text-gray-200">Q{index + 1}</p>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<NumberBadge number={index + 1} active />
|
||||
<Grade grade={grades[currentQuestion.id]} gradeMax={currentQuestion.points} disableGrading={true} />
|
||||
</div>
|
||||
|
||||
@@ -87,8 +141,8 @@
|
||||
{#each questions as currentQuestion, index (currentQuestion.id)}
|
||||
{@const questionModel = questionModels[index]}
|
||||
<div class="mb-4 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-base font-semibold text-gray-700 dark:text-gray-200">Q{index + 1}</p>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<NumberBadge number={index + 1} active />
|
||||
<Grade bind:grade={grades[currentQuestion.id]} gradeMax={currentQuestion.points} {disableGrading} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -289,6 +289,111 @@ export function getExerciseQuestionLabels(): ExerciseQuestionLabels {
|
||||
'file_upload.preview.max_size_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.preview.max_size_label'
|
||||
),
|
||||
'video_recording.edit.max_duration_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.edit.max_duration_label'
|
||||
),
|
||||
'video_recording.edit.max_duration_helper': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.edit.max_duration_helper'
|
||||
),
|
||||
'video_recording.edit.max_duration_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.edit.max_duration_placeholder'
|
||||
),
|
||||
'video_recording.edit.manual_grading': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.edit.manual_grading'
|
||||
),
|
||||
'video_recording.take.unsupported': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.unsupported'
|
||||
),
|
||||
'video_recording.take.permission_prompt': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.permission_prompt'
|
||||
),
|
||||
'video_recording.take.permission_denied': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.permission_denied'
|
||||
),
|
||||
'video_recording.take.start_camera': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.start_camera'
|
||||
),
|
||||
'video_recording.take.camera_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.camera_label'
|
||||
),
|
||||
'video_recording.take.camera_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.camera_placeholder'
|
||||
),
|
||||
'video_recording.take.play_preview': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.play_preview'
|
||||
),
|
||||
'video_recording.take.pause_preview': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.pause_preview'
|
||||
),
|
||||
'video_recording.take.start_recording': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.start_recording'
|
||||
),
|
||||
'video_recording.take.stop_recording': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.stop_recording'
|
||||
),
|
||||
'video_recording.take.elapsed': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.elapsed'
|
||||
),
|
||||
'video_recording.take.remaining': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.remaining'
|
||||
),
|
||||
'video_recording.take.max_duration': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.max_duration'
|
||||
),
|
||||
'video_recording.take.too_short': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.too_short'
|
||||
),
|
||||
'video_recording.take.use_recording': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.use_recording'
|
||||
),
|
||||
'video_recording.take.retake': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.retake'
|
||||
),
|
||||
'video_recording.take.delete': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.delete'
|
||||
),
|
||||
'video_recording.take.retry_upload': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.retry_upload'
|
||||
),
|
||||
'video_recording.take.uploading': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.uploading'
|
||||
),
|
||||
'video_recording.take.uploaded': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.uploaded'
|
||||
),
|
||||
'video_recording.take.upload_failed': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.upload_failed'
|
||||
),
|
||||
'video_recording.take.replaces_previous': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.replaces_previous'
|
||||
),
|
||||
'video_recording.preview.max_duration_label': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.preview.max_duration_label'
|
||||
),
|
||||
'video_recording.preview.manual_grading': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.preview.manual_grading'
|
||||
),
|
||||
'video_recording.submission.no_video': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.submission.no_video'
|
||||
),
|
||||
'video_recording.submission.unavailable': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.submission.unavailable'
|
||||
),
|
||||
'video_recording.submission.duration': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.submission.duration'
|
||||
),
|
||||
'video_recording.submission.file_size': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.submission.file_size'
|
||||
),
|
||||
'video_recording.submission.recorded_at': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.submission.recorded_at'
|
||||
),
|
||||
'video_recording.submission.uploaded_at': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.submission.uploaded_at'
|
||||
),
|
||||
'video_recording.submission.retake_count': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.submission.retake_count'
|
||||
),
|
||||
'link.edit.instructions_placeholder': t.get(
|
||||
'course.navItem.lessons.exercises.all_exercises.shared_question.link.edit.instructions_placeholder'
|
||||
),
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { PREMIUM_QUESTION_TYPE_KEYS, QUESTION_TYPES } from '$features/ui/question/constants';
|
||||
import { PremiumIcon } from '@cio/ui/custom/moving-icons';
|
||||
import { getExerciseEditorQuestionTypeLabel } from './question-type-utils';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
import { openUpgradeModal } from '$lib/utils/functions/org';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { Question } from '$features/course/types';
|
||||
|
||||
type QuestionTypeEntry = (typeof QUESTION_TYPES)[number];
|
||||
|
||||
interface Props {
|
||||
value: string | undefined;
|
||||
onValueChange: (value: string) => void;
|
||||
triggerQuestionType: Question['questionType'] | undefined;
|
||||
types: QuestionTypeEntry[];
|
||||
selfPacedCourse?: boolean;
|
||||
}
|
||||
|
||||
let { value, onValueChange, triggerQuestionType, types, selfPacedCourse = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Select.Root type="single" {value} {onValueChange}>
|
||||
<Select.Trigger class="w-full min-w-0 sm:w-[180px]">
|
||||
{getExerciseEditorQuestionTypeLabel(triggerQuestionType)}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each types as typeEntry (typeEntry.key)}
|
||||
{#if $isFreePlan && PREMIUM_QUESTION_TYPE_KEYS.has(typeEntry.key)}
|
||||
<button
|
||||
type="button"
|
||||
class="ui:flex ui:w-full ui:cursor-pointer ui:select-none ui:items-center ui:gap-2 ui:rounded-sm ui:py-1.5 ui:pl-2 ui:pr-8 ui:text-sm ui:outline-hidden ui:hover:bg-accent ui:hover:text-accent-foreground ui:relative"
|
||||
onclick={(clickEvent) => {
|
||||
clickEvent.preventDefault();
|
||||
clickEvent.stopPropagation();
|
||||
openUpgradeModal();
|
||||
}}
|
||||
title={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.premium_question_type')}
|
||||
>
|
||||
<PremiumIcon size={16} class="ui:text-blue-700 ui:dark:text-white ui:shrink-0" />
|
||||
<span>{getExerciseEditorQuestionTypeLabel(typeEntry)}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<Select.Item value={typeEntry.id.toString()} label={getExerciseEditorQuestionTypeLabel(typeEntry)}>
|
||||
{#snippet children({ selected: _sel })}
|
||||
<span class="flex w-full min-w-0 items-center justify-between gap-2">
|
||||
<span class="truncate">{getExerciseEditorQuestionTypeLabel(typeEntry)}</span>
|
||||
{#if selfPacedCourse}
|
||||
<Badge
|
||||
variant={typeEntry.autoGradable ? 'success' : 'warning'}
|
||||
class="ui:shrink-0 ui:text-[10px] ui:font-normal"
|
||||
>
|
||||
{typeEntry.autoGradable
|
||||
? $t('course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_auto_gradable')
|
||||
: $t('course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_manual_grading')}
|
||||
</Badge>
|
||||
{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
</Select.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
@@ -8,9 +8,19 @@ import {
|
||||
} from '@cio/question-types';
|
||||
import type { Question } from '$features/course/types';
|
||||
import { DEFAULT_QUESTION_TYPE, QUESTION_TYPES } from '$features/ui/question/constants';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
type QuestionTypeOption = (typeof QUESTION_TYPES)[number];
|
||||
|
||||
/** Label for exercise editor picker and lists (translations via labelKey when present). */
|
||||
export function getExerciseEditorQuestionTypeLabel(
|
||||
questionType: Question['questionType'] | QuestionTypeOption | null | undefined
|
||||
): string {
|
||||
if (!questionType) return t.get('course.navItem.lessons.exercises.all_exercises.edit_mode.select_type');
|
||||
if ('labelKey' in questionType && questionType.labelKey) return t.get(questionType.labelKey);
|
||||
return questionType.label ?? t.get('course.navItem.lessons.exercises.all_exercises.edit_mode.select_type');
|
||||
}
|
||||
|
||||
const OPTION_QUESTION_TYPE_KEYS = new Set<ExerciseQuestionTypeKey>([
|
||||
QUESTION_TYPE_KEY.RADIO,
|
||||
QUESTION_TYPE_KEY.CHECKBOX,
|
||||
@@ -54,6 +64,12 @@ export function toExerciseQuestionModel(question: Question): ExerciseQuestionMod
|
||||
|
||||
const rawSettings = (question as Question & { settings?: Record<string, unknown> }).settings;
|
||||
const settings = rawSettings && typeof rawSettings === 'object' ? rawSettings : {};
|
||||
const required =
|
||||
typeof (question as Question & { required?: boolean }).required === 'boolean'
|
||||
? (question as Question & { required?: boolean }).required
|
||||
: typeof settings.required === 'boolean'
|
||||
? settings.required
|
||||
: true;
|
||||
|
||||
return {
|
||||
id: question.id,
|
||||
@@ -61,6 +77,7 @@ export function toExerciseQuestionModel(question: Question): ExerciseQuestionMod
|
||||
title: question.title || '',
|
||||
questionType: getQuestionTypeKey(question),
|
||||
points: Number(question.points || 0),
|
||||
required,
|
||||
options,
|
||||
settings
|
||||
};
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { ExerciseSectionColorTheme } from '@cio/question-types';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import GripVerticalIcon from '@lucide/svelte/icons/grip-vertical';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
|
||||
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
|
||||
|
||||
const COLOR_THEMES: ExerciseSectionColorTheme[] = ['blue', 'green', 'amber', 'rose', 'violet', 'slate'];
|
||||
|
||||
const COLOR_CLASSES: Record<ExerciseSectionColorTheme, string> = {
|
||||
blue: 'bg-blue-500',
|
||||
green: 'bg-emerald-500',
|
||||
amber: 'bg-amber-500',
|
||||
rose: 'bg-rose-500',
|
||||
violet: 'bg-violet-500',
|
||||
slate: 'bg-slate-500'
|
||||
};
|
||||
|
||||
const BORDER_CLASSES: Record<ExerciseSectionColorTheme, string> = {
|
||||
blue: 'border-blue-500',
|
||||
green: 'border-emerald-500',
|
||||
amber: 'border-amber-500',
|
||||
rose: 'border-rose-500',
|
||||
violet: 'border-violet-500',
|
||||
slate: 'border-slate-500'
|
||||
};
|
||||
|
||||
interface Props {
|
||||
exerciseSectionId: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
sectionNumber: number;
|
||||
totalSections: number;
|
||||
colorTheme: ExerciseSectionColorTheme;
|
||||
questionCount: number;
|
||||
requiredQuestionCount: number;
|
||||
titleError?: string;
|
||||
descriptionError?: string;
|
||||
showReorderHandles?: boolean;
|
||||
onTitleChange?: (title: string) => void;
|
||||
onDescriptionChange?: (description: string | null) => void;
|
||||
onColorThemeChange?: (colorTheme: ExerciseSectionColorTheme) => void;
|
||||
onAddQuestion?: () => void;
|
||||
onDelete?: () => void;
|
||||
onMoveUp?: () => void;
|
||||
onMoveDown?: () => void;
|
||||
canMoveUp?: boolean;
|
||||
canMoveDown?: boolean;
|
||||
labels?: Record<string, string>;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
exerciseSectionId,
|
||||
title,
|
||||
description = null,
|
||||
colorTheme,
|
||||
questionCount,
|
||||
requiredQuestionCount,
|
||||
titleError = '',
|
||||
descriptionError = '',
|
||||
showReorderHandles = false,
|
||||
onTitleChange,
|
||||
onDescriptionChange,
|
||||
onColorThemeChange,
|
||||
onAddQuestion,
|
||||
onDelete,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
canMoveUp = false,
|
||||
canMoveDown = false,
|
||||
labels = {},
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
let localTitle = $derived(title);
|
||||
let localDescription = $derived(description ?? '');
|
||||
|
||||
function handleTitleInput() {
|
||||
onTitleChange?.(localTitle);
|
||||
}
|
||||
|
||||
function handleDescriptionInput() {
|
||||
onDescriptionChange?.(localDescription || null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section
|
||||
id={`exercise-section-${exerciseSectionId}`}
|
||||
class={`overflow-hidden rounded-lg border bg-white shadow-xs dark:bg-zinc-950 ${BORDER_CLASSES[colorTheme]}`}
|
||||
>
|
||||
<div class="flex items-center gap-3 border-b bg-zinc-50 px-4 py-3 dark:bg-zinc-900">
|
||||
{#if showReorderHandles}
|
||||
<GripVerticalIcon class="ui:text-muted-foreground h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
{/if}
|
||||
<span class={`h-2.5 w-2.5 shrink-0 rounded-full ${COLOR_CLASSES[colorTheme]}`}></span>
|
||||
<Input
|
||||
bind:value={localTitle}
|
||||
oninput={handleTitleInput}
|
||||
aria-invalid={titleError ? 'true' : undefined}
|
||||
class={`min-w-0 flex-1 border-transparent bg-transparent px-1 font-semibold shadow-none focus-visible:ring-1 ${titleError ? 'ui:border-destructive' : ''}`}
|
||||
/>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
{#each COLOR_THEMES as theme (theme)}
|
||||
<button
|
||||
type="button"
|
||||
class={`ui:border h-3 w-3 cursor-pointer rounded-full ${COLOR_CLASSES[theme]} ${theme === colorTheme ? 'ring-2 ring-offset-2 dark:ring-offset-zinc-900' : ''}`}
|
||||
title={`${labels.color ?? 'Section color'}: ${theme}`}
|
||||
onclick={() => onColorThemeChange?.(theme)}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="ui:text-muted-foreground shrink-0 text-sm font-semibold">
|
||||
{questionCount}
|
||||
{labels.shortQuestions ?? labels.questions ?? 'q'}
|
||||
</span>
|
||||
<IconButton
|
||||
onclick={onDelete}
|
||||
tooltip={labels.delete ?? 'Delete section'}
|
||||
tooltipSide="bottom"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
>
|
||||
<XIcon size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{#if titleError}
|
||||
<div class="bg-white px-4 pt-2 dark:bg-zinc-950">
|
||||
<Field.Error>{titleError}</Field.Error>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="border-b bg-white px-4 py-3 dark:bg-zinc-950">
|
||||
<TextareaField
|
||||
bind:value={localDescription}
|
||||
rows={2}
|
||||
oninput={handleDescriptionInput}
|
||||
placeholder={labels.descriptionPlaceholder ?? 'Describe this section'}
|
||||
errorMessage={descriptionError}
|
||||
className="m-0"
|
||||
bgColor="resize-none border-dashed bg-transparent text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 bg-white px-4 py-4 dark:bg-zinc-950">
|
||||
{@render children?.()}
|
||||
|
||||
<div class="flex justify-center">
|
||||
<Button variant="outline" onclick={onAddQuestion}>+ {labels.addQuestion ?? 'Add question'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between border-t bg-zinc-50 px-4 py-3 dark:bg-zinc-900">
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
{requiredQuestionCount}
|
||||
{labels.required ?? 'required'}
|
||||
</p>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton
|
||||
onclick={onMoveUp}
|
||||
disabled={!canMoveUp}
|
||||
tooltip={labels.moveUp ?? 'Move section up'}
|
||||
tooltipSide="bottom"
|
||||
>
|
||||
<ArrowUpIcon size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onclick={onMoveDown}
|
||||
disabled={!canMoveDown}
|
||||
tooltip={labels.moveDown ?? 'Move section down'}
|
||||
tooltipSide="bottom"
|
||||
>
|
||||
<ArrowDownIcon size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,18 +1,33 @@
|
||||
import { DEFAULT_QUESTION_TYPE, QUESTION_TEMPLATE } from '$features/ui/question/constants';
|
||||
import type { AnswerData } from '@cio/question-types';
|
||||
import type {
|
||||
AnswerData,
|
||||
ExerciseSectionAfterBehavior,
|
||||
ExerciseSectionColorTheme,
|
||||
ExerciseSectionDisplayMode
|
||||
} from '@cio/question-types';
|
||||
|
||||
import type { Question } from '$features/course/types';
|
||||
import { STATUS } from './constants';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import { isUUID } from '$lib/utils/functions/isUUID';
|
||||
import { writable } from 'svelte/store';
|
||||
import { UNTITLED_EXERCISE_SECTION_TITLE } from '$features/course/utils/exercise-section-utils';
|
||||
|
||||
export const isQuestionnaireFetching = writable(false);
|
||||
export const deleteConfirmation = writable({ open: false });
|
||||
export const questionnaireOrder = writable({ open: false });
|
||||
|
||||
// {'question-id': { option: '', title: '', ... }}
|
||||
export const questionnaireValidation = writable({});
|
||||
export interface ExerciseEditorFieldErrors {
|
||||
option?: string;
|
||||
title?: string;
|
||||
points?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type ExerciseEditorErrors = Record<string, ExerciseEditorFieldErrors>;
|
||||
|
||||
// {'question-or-section-id': { option: '', title: '', description: '', ... }}
|
||||
export const questionnaireValidation = writable<ExerciseEditorErrors>({});
|
||||
|
||||
export interface QuestionnaireMetaData {
|
||||
answers: Record<string, AnswerData | undefined>;
|
||||
@@ -27,6 +42,34 @@ export interface QuestionnaireMetaData {
|
||||
comment: string;
|
||||
/** Set when exercise is finished so route knows not to overwrite questionnaire */
|
||||
exerciseId: string | null;
|
||||
currentSectionIndex: number;
|
||||
sectionPhase: 'overview' | 'questions';
|
||||
}
|
||||
|
||||
export interface ExerciseSectionState {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
order: number;
|
||||
colorTheme: ExerciseSectionColorTheme;
|
||||
afterBehavior: ExerciseSectionAfterBehavior;
|
||||
isDirty?: boolean;
|
||||
deletedAt?: string;
|
||||
}
|
||||
|
||||
export interface QuestionnaireState {
|
||||
title?: string | null;
|
||||
dueBy?: string | null;
|
||||
isDueByDirty?: boolean;
|
||||
isTitleDirty?: boolean;
|
||||
description?: string | null;
|
||||
isDescriptionDirty?: boolean;
|
||||
questions: Question[];
|
||||
sections: ExerciseSectionState[];
|
||||
sectionDisplayMode: ExerciseSectionDisplayMode;
|
||||
totalSubmissions: number;
|
||||
allowMultipleAttempts?: boolean;
|
||||
slug?: string | null;
|
||||
}
|
||||
|
||||
const initAnswerState: QuestionnaireMetaData = {
|
||||
@@ -40,22 +83,14 @@ const initAnswerState: QuestionnaireMetaData = {
|
||||
progressValue: 0,
|
||||
status: STATUS.PENDING,
|
||||
comment: '',
|
||||
exerciseId: null
|
||||
exerciseId: null,
|
||||
currentSectionIndex: 0,
|
||||
sectionPhase: 'overview'
|
||||
};
|
||||
|
||||
export const questionnaireMetaData: Writable<QuestionnaireMetaData> = writable(initAnswerState);
|
||||
|
||||
export const questionnaire: Writable<{
|
||||
title?: string | null;
|
||||
dueBy?: string | null;
|
||||
isDueByDirty?: boolean;
|
||||
isTitleDirty?: boolean;
|
||||
description?: string | null;
|
||||
isDescriptionDirty?: boolean;
|
||||
questions: Question[];
|
||||
totalSubmissions: number;
|
||||
allowMultipleAttempts?: boolean;
|
||||
}> = writable({
|
||||
export const questionnaire: Writable<QuestionnaireState> = writable({
|
||||
title: '',
|
||||
dueBy: '',
|
||||
isDueByDirty: false,
|
||||
@@ -63,8 +98,11 @@ export const questionnaire: Writable<{
|
||||
description: '',
|
||||
isDescriptionDirty: false,
|
||||
questions: [],
|
||||
sections: [],
|
||||
sectionDisplayMode: 'one_question',
|
||||
totalSubmissions: 0,
|
||||
allowMultipleAttempts: false
|
||||
allowMultipleAttempts: false,
|
||||
slug: ''
|
||||
});
|
||||
|
||||
export function clearQuestionnaireValidation() {
|
||||
@@ -84,7 +122,9 @@ export function reset() {
|
||||
finalTotalGrade: 0,
|
||||
status: STATUS.PENDING,
|
||||
comment: '',
|
||||
progressValue: 100
|
||||
progressValue: 100,
|
||||
currentSectionIndex: 0,
|
||||
sectionPhase: 'overview'
|
||||
}));
|
||||
|
||||
clearQuestionnaireValidation();
|
||||
@@ -106,8 +146,8 @@ export function resetStudentExerciseTake() {
|
||||
export function mapZodErrorsToQuestionErrors(
|
||||
zodErrors: Record<string, string>,
|
||||
questions: Question[]
|
||||
): Record<string, { option?: string; title?: string; points?: string }> {
|
||||
const errors: Record<string, { option?: string; title?: string; points?: string }> = {};
|
||||
): ExerciseEditorErrors {
|
||||
const errors: ExerciseEditorErrors = {};
|
||||
for (const [path, message] of Object.entries(zodErrors)) {
|
||||
const pathParts = path.split('.');
|
||||
const questionIndex = pathParts[0] === 'questions' ? parseInt(pathParts[1]) : -1;
|
||||
@@ -134,20 +174,45 @@ export function mapZodErrorsToQuestionErrors(
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function handleAddQuestion() {
|
||||
export function hasSections(sections: ExerciseSectionState[]) {
|
||||
return sections.some((section) => !section.deletedAt);
|
||||
}
|
||||
|
||||
function compareQuestionsByOrder(leftQuestion: Question, rightQuestion: Question) {
|
||||
const leftOrder = leftQuestion.order ?? 0;
|
||||
const rightOrder = rightQuestion.order ?? 0;
|
||||
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
|
||||
|
||||
return String(leftQuestion.id).localeCompare(String(rightQuestion.id), undefined, { numeric: true });
|
||||
}
|
||||
|
||||
export function getQuestionsForSection(questions: Question[], exerciseSectionId: string) {
|
||||
return questions
|
||||
.filter((question) => !question.deletedAt && question.exerciseSectionId === exerciseSectionId)
|
||||
.sort(compareQuestionsByOrder);
|
||||
}
|
||||
|
||||
export function handleAddQuestion(exerciseSectionId?: string | null) {
|
||||
let nextQuestionId: string | number = '';
|
||||
|
||||
questionnaire.update((q) => {
|
||||
const { questions } = q;
|
||||
const activeSections = q.sections.filter((section) => !section.deletedAt);
|
||||
const nextExerciseSectionId = exerciseSectionId ?? (activeSections.length > 0 ? activeSections[0].id : null);
|
||||
nextQuestionId = `${questions.length + 1}-form`;
|
||||
|
||||
return {
|
||||
...q,
|
||||
questions: [
|
||||
...questions,
|
||||
{
|
||||
...QUESTION_TEMPLATE,
|
||||
id: `${questions.length + 1}-form`,
|
||||
name: `${questions.length + 1}-form`,
|
||||
id: nextQuestionId,
|
||||
name: `${nextQuestionId}`,
|
||||
value: '',
|
||||
points: 0,
|
||||
order: questions.length,
|
||||
exerciseSectionId: nextExerciseSectionId,
|
||||
questionType: DEFAULT_QUESTION_TYPE,
|
||||
questionTypeId: DEFAULT_QUESTION_TYPE.id,
|
||||
options: [
|
||||
@@ -162,6 +227,217 @@ export function handleAddQuestion() {
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
return nextQuestionId;
|
||||
}
|
||||
|
||||
export function handleAddSection() {
|
||||
let nextSectionId = '';
|
||||
|
||||
questionnaire.update((q) => {
|
||||
const activeSections = q.sections.filter((section) => !section.deletedAt);
|
||||
const firstAvailableSectionOrder =
|
||||
activeSections.reduce((highestOrder, section) => Math.max(highestOrder, section.order), -1) + 1;
|
||||
const activeUnsectionedQuestions = q.questions.filter(
|
||||
(question) => !question.deletedAt && !question.exerciseSectionId
|
||||
);
|
||||
const nextSectionDisplayNumber = activeSections.length + (activeUnsectionedQuestions.length > 0 ? 2 : 1);
|
||||
const nextSections = [...q.sections];
|
||||
let questions = q.questions;
|
||||
let nextSectionOrder = firstAvailableSectionOrder;
|
||||
|
||||
if (activeUnsectionedQuestions.length > 0) {
|
||||
const untitledSectionId = crypto.randomUUID();
|
||||
const untitledSection: ExerciseSectionState = {
|
||||
id: untitledSectionId,
|
||||
title: UNTITLED_EXERCISE_SECTION_TITLE,
|
||||
description: null,
|
||||
order: nextSectionOrder,
|
||||
colorTheme: 'blue',
|
||||
afterBehavior: { action: 'continue' },
|
||||
isDirty: true
|
||||
};
|
||||
|
||||
nextSectionOrder += 1;
|
||||
nextSections.push(untitledSection);
|
||||
questions = q.questions.map((question) => {
|
||||
if (question.deletedAt || question.exerciseSectionId) return question;
|
||||
|
||||
return {
|
||||
...question,
|
||||
exerciseSectionId: untitledSectionId,
|
||||
isDirty: true
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const sectionId = crypto.randomUUID();
|
||||
nextSectionId = sectionId;
|
||||
const nextSection: ExerciseSectionState = {
|
||||
id: sectionId,
|
||||
title: `Section ${nextSectionDisplayNumber}`,
|
||||
description: null,
|
||||
order: nextSectionOrder,
|
||||
colorTheme: 'blue',
|
||||
afterBehavior: { action: 'continue' },
|
||||
isDirty: true
|
||||
};
|
||||
|
||||
return {
|
||||
...q,
|
||||
questions,
|
||||
sections: [...nextSections, nextSection]
|
||||
};
|
||||
});
|
||||
|
||||
return nextSectionId;
|
||||
}
|
||||
|
||||
export function handleRemoveSection(exerciseSectionId: string, moveQuestionsToExerciseSectionId?: string | null) {
|
||||
questionnaire.update((q) => {
|
||||
const activeSections = q.sections.filter((section) => !section.deletedAt);
|
||||
const remainingSections = activeSections.filter((section) => section.id !== exerciseSectionId);
|
||||
const shouldClearSections = remainingSections.length === 0;
|
||||
const deletedAt = new Date().toString();
|
||||
|
||||
return {
|
||||
...q,
|
||||
sections: q.sections.map((section) =>
|
||||
section.id === exerciseSectionId ? { ...section, deletedAt, isDirty: true } : section
|
||||
),
|
||||
questions: q.questions.map((question) => {
|
||||
if (question.exerciseSectionId !== exerciseSectionId) return question;
|
||||
|
||||
if (moveQuestionsToExerciseSectionId || shouldClearSections) {
|
||||
return {
|
||||
...question,
|
||||
exerciseSectionId: shouldClearSections ? null : moveQuestionsToExerciseSectionId,
|
||||
isDirty: true
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...question,
|
||||
deletedAt,
|
||||
isDirty: true
|
||||
};
|
||||
})
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function handleMoveQuestionToSection(questionId: string | number, exerciseSectionId: string | null) {
|
||||
questionnaire.update((q) => ({
|
||||
...q,
|
||||
questions: q.questions.map((question) =>
|
||||
question.id === questionId ? { ...question, exerciseSectionId, isDirty: true } : question
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
export function handleUpdateSectionAfterBehavior(
|
||||
exerciseSectionId: string,
|
||||
afterBehavior: ExerciseSectionAfterBehavior
|
||||
) {
|
||||
questionnaire.update((q) => ({
|
||||
...q,
|
||||
sections: q.sections.map((section) =>
|
||||
section.id === exerciseSectionId ? { ...section, afterBehavior, isDirty: true } : section
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
export function handleUpdateSectionColorTheme(exerciseSectionId: string, colorTheme: ExerciseSectionColorTheme) {
|
||||
questionnaire.update((q) => ({
|
||||
...q,
|
||||
sections: q.sections.map((section) =>
|
||||
section.id === exerciseSectionId ? { ...section, colorTheme, isDirty: true } : section
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
export function handleUpdateSectionTitle(exerciseSectionId: string, title: string) {
|
||||
questionnaire.update((q) => ({
|
||||
...q,
|
||||
sections: q.sections.map((section) =>
|
||||
section.id === exerciseSectionId ? { ...section, title, isDirty: true } : section
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
export function handleUpdateSectionDescription(exerciseSectionId: string, description: string | null) {
|
||||
questionnaire.update((q) => ({
|
||||
...q,
|
||||
sections: q.sections.map((section) =>
|
||||
section.id === exerciseSectionId ? { ...section, description, isDirty: true } : section
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
export function handleReorderSections(orderedSectionIds: string[]) {
|
||||
const orderBySectionId = new Map(orderedSectionIds.map((sectionId, index) => [sectionId, index]));
|
||||
|
||||
questionnaire.update((q) => ({
|
||||
...q,
|
||||
sections: q.sections.map((section) => {
|
||||
const nextOrder = orderBySectionId.get(section.id);
|
||||
if (nextOrder === undefined || nextOrder === section.order) return section;
|
||||
|
||||
return { ...section, order: nextOrder, isDirty: true };
|
||||
})
|
||||
}));
|
||||
}
|
||||
|
||||
export function handleReorderQuestionsInSection(exerciseSectionId: string, orderedQuestionIds: Array<string | number>) {
|
||||
questionnaire.update((q) => {
|
||||
const activeSections = [...q.sections].filter((section) => !section.deletedAt).sort((a, b) => a.order - b.order);
|
||||
const sectionExists = activeSections.some((section) => section.id === exerciseSectionId);
|
||||
if (!sectionExists) return q;
|
||||
|
||||
const questionById = new Map(q.questions.map((question) => [String(question.id), question]));
|
||||
const orderedQuestionIdSet = new Set(orderedQuestionIds.map((questionId) => String(questionId)));
|
||||
const reorderedActiveQuestions: Question[] = [];
|
||||
|
||||
for (const section of activeSections) {
|
||||
const sectionQuestions = getQuestionsForSection(q.questions, section.id);
|
||||
|
||||
if (section.id !== exerciseSectionId) {
|
||||
reorderedActiveQuestions.push(...sectionQuestions);
|
||||
continue;
|
||||
}
|
||||
|
||||
const orderedSectionQuestions = orderedQuestionIds
|
||||
.map((questionId) => questionById.get(String(questionId)))
|
||||
.filter(
|
||||
(question): question is Question =>
|
||||
Boolean(question) && !question.deletedAt && question.exerciseSectionId === exerciseSectionId
|
||||
);
|
||||
const missingSectionQuestions = sectionQuestions.filter(
|
||||
(question) => !orderedQuestionIdSet.has(String(question.id))
|
||||
);
|
||||
reorderedActiveQuestions.push(...orderedSectionQuestions, ...missingSectionQuestions);
|
||||
}
|
||||
|
||||
const activeUnsectionedQuestions = q.questions
|
||||
.filter((question) => !question.deletedAt && !question.exerciseSectionId)
|
||||
.sort(compareQuestionsByOrder);
|
||||
const nextOrderByQuestionId = new Map(
|
||||
[...reorderedActiveQuestions, ...activeUnsectionedQuestions].map((question, index) => [
|
||||
String(question.id),
|
||||
index + 1
|
||||
])
|
||||
);
|
||||
|
||||
return {
|
||||
...q,
|
||||
questions: q.questions.map((question) => {
|
||||
const nextOrder = nextOrderByQuestionId.get(String(question.id));
|
||||
if (nextOrder === undefined || question.order === nextOrder) return question;
|
||||
|
||||
return { ...question, order: nextOrder, isDirty: true };
|
||||
})
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function handleAddOption(questionId) {
|
||||
|
||||
+72
-3
@@ -4,13 +4,14 @@
|
||||
import { UserAvatar } from '@cio/ui/custom/user-avatar';
|
||||
|
||||
import { submissions } from './store';
|
||||
import { questionnaire } from '../store';
|
||||
import { getQuestionsForSection, questionnaire } from '../store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { ExerciseSubmissions } from './types';
|
||||
import type { Question } from '$features/course/types';
|
||||
import type { AnswerData } from '@cio/question-types';
|
||||
import { toExerciseQuestionModel } from '../question-type-utils';
|
||||
import { getExerciseQuestionLabels } from '../question-labels';
|
||||
import { getExerciseSectionDisplayTitle } from '$features/course/utils/exercise-section-utils';
|
||||
|
||||
interface Props {
|
||||
isLoading?: boolean;
|
||||
@@ -20,6 +21,11 @@
|
||||
|
||||
let studentSelected = $state(0);
|
||||
const questionLabels = $derived(getExerciseQuestionLabels());
|
||||
const sectionFallbackTitle = $derived($t('course.navItem.lessons.exercises.all_exercises.section.fallback_title'));
|
||||
const activeSections = $derived(
|
||||
[...($questionnaire.sections ?? [])].filter((section) => !section.deletedAt).sort((a, b) => a.order - b.order)
|
||||
);
|
||||
const hasSectionGroups = $derived(activeSections.length > 0);
|
||||
|
||||
function toNumericQuestionId(question: Question): number | null {
|
||||
const numericId = Number(question.id);
|
||||
@@ -42,6 +48,20 @@
|
||||
|
||||
return submittedAnswer.answerData;
|
||||
}
|
||||
|
||||
function getSectionScore(student: ExerciseSubmissions, questions: Question[]) {
|
||||
return questions.reduce((total, question) => {
|
||||
const questionId = toNumericQuestionId(question);
|
||||
if (questionId === null) return total;
|
||||
|
||||
const answer = student.answers.find((item) => item.questionId === questionId);
|
||||
return total + (answer?.point ?? 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function getSectionMaxPoints(questions: Question[]) {
|
||||
return questions.reduce((total, question) => total + Number(question.points ?? 0), 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isLoading}
|
||||
@@ -74,17 +94,66 @@
|
||||
)}
|
||||
</p>
|
||||
{#if $submissions[studentSelected]}
|
||||
{#if $questionnaire.questions}
|
||||
{#if hasSectionGroups}
|
||||
<div class="space-y-8">
|
||||
{#each activeSections as section, sectionIndex (section.id)}
|
||||
{@const sectionQuestions = getQuestionsForSection($questionnaire.questions, section.id)}
|
||||
<section class="space-y-4">
|
||||
<ExerciseQuestion.SectionHeader
|
||||
title={getExerciseSectionDisplayTitle({
|
||||
title: section.title,
|
||||
sectionNumber: sectionIndex + 1,
|
||||
sectionLabel: sectionFallbackTitle
|
||||
})}
|
||||
description={section.description}
|
||||
sectionNumber={sectionIndex + 1}
|
||||
totalSections={activeSections.length}
|
||||
colorTheme={section.colorTheme}
|
||||
questionCount={sectionQuestions.length}
|
||||
totalPoints={getSectionMaxPoints(sectionQuestions)}
|
||||
labels={{
|
||||
section: $t('course.navItem.lessons.exercises.all_exercises.section.fallback_title'),
|
||||
questions: $t('course.navItem.lessons.exercises.all_exercises.view_mode.questions'),
|
||||
points: $t('course.navItem.lessons.exercises.all_exercises.view_mode.points')
|
||||
}}
|
||||
/>
|
||||
|
||||
{#each sectionQuestions as q, i (`${q.id}-${i}`)}
|
||||
<div class="pb-4">
|
||||
<ExerciseQuestion.QuestionRenderer
|
||||
contract={{
|
||||
mode: 'review',
|
||||
question: toExerciseQuestionModel(q),
|
||||
answer: getStudentAnswerForQuestion($submissions[studentSelected], q),
|
||||
labels: questionLabels,
|
||||
disabled: true
|
||||
}}
|
||||
questionNumber={i + 1}
|
||||
questionNumberActive={false}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<p class="border-t pt-2 text-sm font-medium">
|
||||
{getSectionScore($submissions[studentSelected], sectionQuestions)}/{getSectionMaxPoints(sectionQuestions)}
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.points')}
|
||||
</p>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if $questionnaire.questions}
|
||||
{#each $questionnaire.questions as q, i (`${q.id}-${i}`)}
|
||||
<div class="pb-4">
|
||||
<ExerciseQuestion.QuestionRenderer
|
||||
contract={{
|
||||
mode: 'review',
|
||||
question: { ...toExerciseQuestionModel(q), title: `${i + 1}. ${q.title}` },
|
||||
question: toExerciseQuestionModel(q),
|
||||
answer: getStudentAnswerForQuestion($submissions[studentSelected], q),
|
||||
labels: questionLabels,
|
||||
disabled: true
|
||||
}}
|
||||
questionNumber={i + 1}
|
||||
questionNumberActive={false}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
+59
-18
@@ -4,18 +4,20 @@
|
||||
import { ExerciseQuestion } from '@cio/ui';
|
||||
|
||||
import { submissions } from './store';
|
||||
import { questionnaire } from '../store';
|
||||
import { getQuestionsForSection, questionnaire } from '../store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { Question } from '$features/course/types';
|
||||
import type { ExerciseSubmissions } from './types';
|
||||
import type { AnswerData, ExerciseQuestionLabels, ExerciseSubmissionModel } from '@cio/question-types';
|
||||
import { toExerciseQuestionModel } from '../question-type-utils';
|
||||
import { getExerciseSectionDisplayTitle } from '$features/course/utils/exercise-section-utils';
|
||||
|
||||
interface Props {
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
let { isLoading = $bindable(true) }: Props = $props();
|
||||
const sectionFallbackTitle = $derived($t('course.navItem.lessons.exercises.all_exercises.section.fallback_title'));
|
||||
|
||||
function toAnswerData(answerData: unknown): AnswerData | null {
|
||||
if (!answerData || typeof answerData !== 'object' || !('type' in answerData)) return null;
|
||||
@@ -34,13 +36,6 @@
|
||||
};
|
||||
}
|
||||
|
||||
function toSubmissionQuestionModel(question: Question, index: number) {
|
||||
return {
|
||||
...toExerciseQuestionModel(question),
|
||||
title: `${index + 1}. ${question.title || ''}`
|
||||
};
|
||||
}
|
||||
|
||||
const submissionLabels = $derived.by(
|
||||
(): ExerciseQuestionLabels => ({
|
||||
'submission.common.no_answer': $t('course.navItem.lessons.exercises.all_exercises.analytics.individual.no'),
|
||||
@@ -59,8 +54,16 @@
|
||||
const submissionQuestions = $derived(
|
||||
$questionnaire.questions
|
||||
.filter((question) => !question.deletedAt)
|
||||
.map((question, index) => toSubmissionQuestionModel(question, index))
|
||||
.map((question) => toExerciseQuestionModel(question))
|
||||
);
|
||||
const activeSections = $derived(
|
||||
[...($questionnaire.sections ?? [])].filter((section) => !section.deletedAt).sort((a, b) => a.order - b.order)
|
||||
);
|
||||
const hasSectionGroups = $derived(activeSections.length > 0);
|
||||
|
||||
function getSectionPoints(questions: Question[]) {
|
||||
return questions.reduce((total, question) => total + Number(question.points ?? 0), 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isLoading}
|
||||
@@ -71,14 +74,52 @@
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.analytics.summary.question_chart')}
|
||||
</p>
|
||||
|
||||
<ExerciseQuestion.QuestionList
|
||||
contract={{
|
||||
mode: 'submission',
|
||||
questions: submissionQuestions,
|
||||
submissions: submissionModels,
|
||||
labels: submissionLabels,
|
||||
disabled: true
|
||||
}}
|
||||
/>
|
||||
{#if hasSectionGroups}
|
||||
<div class="space-y-8">
|
||||
{#each activeSections as section, sectionIndex (section.id)}
|
||||
{@const sectionQuestions = getQuestionsForSection($questionnaire.questions, section.id)}
|
||||
<section class="space-y-4">
|
||||
<ExerciseQuestion.SectionHeader
|
||||
title={getExerciseSectionDisplayTitle({
|
||||
title: section.title,
|
||||
sectionNumber: sectionIndex + 1,
|
||||
sectionLabel: sectionFallbackTitle
|
||||
})}
|
||||
description={section.description}
|
||||
sectionNumber={sectionIndex + 1}
|
||||
totalSections={activeSections.length}
|
||||
colorTheme={section.colorTheme}
|
||||
questionCount={sectionQuestions.length}
|
||||
totalPoints={getSectionPoints(sectionQuestions)}
|
||||
labels={{
|
||||
section: $t('course.navItem.lessons.exercises.all_exercises.section.fallback_title'),
|
||||
questions: $t('course.navItem.lessons.exercises.all_exercises.view_mode.questions'),
|
||||
points: $t('course.navItem.lessons.exercises.all_exercises.view_mode.points')
|
||||
}}
|
||||
/>
|
||||
|
||||
<ExerciseQuestion.QuestionList
|
||||
contract={{
|
||||
mode: 'submission',
|
||||
questions: sectionQuestions.map((question) => toExerciseQuestionModel(question)),
|
||||
submissions: submissionModels,
|
||||
labels: submissionLabels,
|
||||
disabled: true
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<ExerciseQuestion.QuestionList
|
||||
contract={{
|
||||
mode: 'submission',
|
||||
questions: submissionQuestions,
|
||||
submissions: submissionModels,
|
||||
labels: submissionLabels,
|
||||
disabled: true
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -4,7 +4,13 @@
|
||||
import { fly } from 'svelte/transition';
|
||||
import { cubicInOut } from 'svelte/easing';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { questionnaire, questionnaireMetaData, resetStudentExerciseTake } from './store';
|
||||
import {
|
||||
getQuestionsForSection,
|
||||
hasSections,
|
||||
questionnaire,
|
||||
questionnaireMetaData,
|
||||
resetStudentExerciseTake
|
||||
} from './store';
|
||||
import Preview from './preview.svelte';
|
||||
import { ExerciseQuestion } from '@cio/ui';
|
||||
import * as Alert from '@cio/ui/base/alert';
|
||||
@@ -22,6 +28,7 @@
|
||||
getTextareaAnswerCharacterCount,
|
||||
toApiPayload,
|
||||
type AnswerData,
|
||||
type ExerciseQuestionVideoRecordingUploader,
|
||||
validateTextareaAnswer
|
||||
} from '@cio/question-types';
|
||||
import { exerciseApi, presignApi } from '$features/course/api';
|
||||
@@ -37,10 +44,12 @@
|
||||
import { isSelfPacedLikeCourse } from '$features/course/utils/compliance-utils';
|
||||
import { toExerciseQuestionModel } from './question-type-utils';
|
||||
import { getExerciseQuestionLabels } from './question-labels';
|
||||
import { getExerciseSectionDisplayTitle } from '$features/course/utils/exercise-section-utils';
|
||||
import axios from 'axios';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
import type { Question } from '$features/course/types';
|
||||
|
||||
interface Props {
|
||||
preview?: boolean;
|
||||
@@ -61,11 +70,17 @@
|
||||
let slideDirection = $state<'next' | 'prev'>('next');
|
||||
let selectedTryIndex = $state(-1);
|
||||
const questionLabels = $derived(getExerciseQuestionLabels());
|
||||
const sectionFallbackTitle = $derived($t('course.navItem.lessons.exercises.all_exercises.section.fallback_title'));
|
||||
|
||||
const submissionList = $derived(Array.isArray(mySubmissions) ? mySubmissions : []);
|
||||
|
||||
function handleStart() {
|
||||
questionnaireMetaData.update((m) => ({ ...m, currentQuestionIndex: m.currentQuestionIndex + 1 }));
|
||||
questionnaireMetaData.update((m) => ({
|
||||
...m,
|
||||
currentQuestionIndex: 1,
|
||||
currentSectionIndex: 0,
|
||||
sectionPhase: hasSectionedExercise ? 'overview' : 'questions'
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeAnswerValue(
|
||||
@@ -84,11 +99,107 @@
|
||||
return toApiPayload(answerData, questionId);
|
||||
}
|
||||
|
||||
async function submitExercise() {
|
||||
if (!courseApi.course?.id || isSubmitting) return;
|
||||
|
||||
isSubmitting = true;
|
||||
|
||||
const updated = get(questionnaireMetaData);
|
||||
localStorage.removeItem(`autosave-exercise-${exerciseId}`);
|
||||
const totalPossibleGrade = getTotalPossibleGrade($questionnaire.questions);
|
||||
|
||||
const answersForApi = Object.entries(updated.answers)
|
||||
.map(([questionKey, val]) => {
|
||||
const question = $questionnaire.questions.find(
|
||||
(item) => getExerciseQuestionContractKey(toExerciseQuestionModel(item)) === questionKey
|
||||
);
|
||||
if (!question) return null;
|
||||
return mapAnswerToApiPayload(question, val);
|
||||
})
|
||||
.filter((answer) => answer !== null) as Array<{ questionId: number; optionId?: number; answer?: string }>;
|
||||
|
||||
if (answersForApi.length === 0) {
|
||||
isSubmitting = false;
|
||||
snackbar.error(t.get('course.navItem.lessons.exercises.all_exercises.view_mode.answers_required'));
|
||||
return;
|
||||
}
|
||||
|
||||
const submitResult = await exerciseApi.submit(courseApi.course.id, exerciseId, answersForApi);
|
||||
|
||||
if (exerciseApi.success && submitResult?.data) {
|
||||
skipHydrateFromSubmissions = false;
|
||||
const sub = submitResult.data as unknown as SubmissionListItem & { gradingState?: string };
|
||||
if (sub.gradingState === 'completed' && sub.statusId === STATUS.GRADED && Array.isArray(sub.answers)) {
|
||||
applyMySubmission(sub);
|
||||
} else {
|
||||
questionnaireMetaData.update((m) => ({
|
||||
...m,
|
||||
status: 1,
|
||||
totalPossibleGrade,
|
||||
grades: {},
|
||||
comment: '',
|
||||
isFinished: true,
|
||||
exerciseId
|
||||
}));
|
||||
}
|
||||
courseApi.updateContentItem(exerciseId, ContentType.Exercise, { isComplete: true });
|
||||
|
||||
toggleConfetti();
|
||||
setTimeout(toggleConfetti, 3000);
|
||||
|
||||
const allContentItems = getOrderedNavigableContent(courseApi.course);
|
||||
const allComplete =
|
||||
$isOrgStudent && allContentItems.length > 0 && allContentItems.every((item) => item.isComplete);
|
||||
|
||||
if (allComplete && courseApi.course?.id) {
|
||||
openCourseCompletionModal(courseApi.course.id);
|
||||
}
|
||||
}
|
||||
|
||||
isSubmitting = false;
|
||||
}
|
||||
|
||||
function evaluateSectionAfterBehavior() {
|
||||
if (!currentSection) return;
|
||||
|
||||
const behavior = currentSection.afterBehavior;
|
||||
if (behavior.action === 'submit') {
|
||||
submitExercise();
|
||||
return;
|
||||
}
|
||||
|
||||
if (behavior.action === 'go_to_section' && behavior.exerciseSectionId) {
|
||||
const targetIndex = activeSections.findIndex((section) => section.id === behavior.exerciseSectionId);
|
||||
if (targetIndex >= 0) {
|
||||
questionnaireMetaData.update((m) => ({
|
||||
...m,
|
||||
currentSectionIndex: targetIndex,
|
||||
currentQuestionIndex: 1,
|
||||
sectionPhase: 'overview'
|
||||
}));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const nextSectionIndex = $questionnaireMetaData.currentSectionIndex + 1;
|
||||
if (nextSectionIndex < activeSections.length) {
|
||||
questionnaireMetaData.update((m) => ({
|
||||
...m,
|
||||
currentSectionIndex: nextSectionIndex,
|
||||
currentQuestionIndex: 1,
|
||||
sectionPhase: 'overview'
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
submitExercise();
|
||||
}
|
||||
|
||||
async function onSubmit(id, value) {
|
||||
if (!courseApi.course?.id) return;
|
||||
|
||||
const { answers } = $questionnaireMetaData;
|
||||
const { questions } = $questionnaire;
|
||||
const questions = hasSectionedExercise ? currentSectionQuestions : $questionnaire.questions;
|
||||
const prevAnswer = answers[id];
|
||||
const formattedAnswer = normalizeAnswerValue(prevAnswer, value);
|
||||
|
||||
@@ -110,63 +221,11 @@
|
||||
const isLastQuestion = currentIndex === questions.length;
|
||||
|
||||
if (isLastQuestion) {
|
||||
// Stay on last question, show spinner, submit to server
|
||||
if (isSubmitting) return;
|
||||
isSubmitting = true;
|
||||
|
||||
const updated = get(questionnaireMetaData);
|
||||
localStorage.removeItem(`autosave-exercise-${exerciseId}`);
|
||||
const totalPossibleGrade = getTotalPossibleGrade($questionnaire.questions);
|
||||
|
||||
const answersForApi = Object.entries(updated.answers)
|
||||
.map(([questionKey, val]) => {
|
||||
const question = questions.find(
|
||||
(item) => getExerciseQuestionContractKey(toExerciseQuestionModel(item)) === questionKey
|
||||
);
|
||||
if (!question) return null;
|
||||
return mapAnswerToApiPayload(question, val);
|
||||
})
|
||||
.filter((answer) => answer !== null) as Array<{ questionId: number; optionId?: number; answer?: string }>;
|
||||
|
||||
if (answersForApi.length === 0) {
|
||||
isSubmitting = false;
|
||||
snackbar.error(t.get('course.navItem.lessons.exercises.all_exercises.view_mode.answers_required'));
|
||||
return;
|
||||
if (hasSectionedExercise) {
|
||||
evaluateSectionAfterBehavior();
|
||||
} else {
|
||||
await submitExercise();
|
||||
}
|
||||
|
||||
const submitResult = await exerciseApi.submit(courseApi.course.id, exerciseId, answersForApi);
|
||||
|
||||
if (exerciseApi.success && submitResult?.data) {
|
||||
skipHydrateFromSubmissions = false;
|
||||
const sub = submitResult.data as unknown as SubmissionListItem & { gradingState?: string };
|
||||
if (sub.gradingState === 'completed' && sub.statusId === STATUS.GRADED && Array.isArray(sub.answers)) {
|
||||
applyMySubmission(sub);
|
||||
} else {
|
||||
questionnaireMetaData.update((m) => ({
|
||||
...m,
|
||||
status: 1,
|
||||
totalPossibleGrade,
|
||||
grades: {},
|
||||
comment: '',
|
||||
isFinished: true,
|
||||
exerciseId
|
||||
}));
|
||||
}
|
||||
courseApi.updateContentItem(exerciseId, ContentType.Exercise, { isComplete: true });
|
||||
|
||||
toggleConfetti();
|
||||
setTimeout(toggleConfetti, 3000);
|
||||
|
||||
const allContentItems = getOrderedNavigableContent(courseApi.course);
|
||||
const allComplete =
|
||||
$isOrgStudent && allContentItems.length > 0 && allContentItems.every((item) => item.isComplete);
|
||||
|
||||
if (allComplete && courseApi.course?.id) {
|
||||
openCourseCompletionModal(courseApi.course.id);
|
||||
}
|
||||
}
|
||||
|
||||
isSubmitting = false;
|
||||
} else {
|
||||
// Advance to next question
|
||||
slideDirection = 'next';
|
||||
@@ -177,6 +236,11 @@
|
||||
}
|
||||
|
||||
function onPrevious() {
|
||||
if (hasSectionedExercise && $questionnaireMetaData.currentQuestionIndex <= 1) {
|
||||
questionnaireMetaData.update((m) => ({ ...m, sectionPhase: 'overview' }));
|
||||
return;
|
||||
}
|
||||
|
||||
slideDirection = 'prev';
|
||||
questionnaireMetaData.update((m) => ({ ...m, currentQuestionIndex: m.currentQuestionIndex - 1 }));
|
||||
}
|
||||
@@ -186,6 +250,14 @@
|
||||
return 100;
|
||||
}
|
||||
|
||||
if (hasSectionedExercise) {
|
||||
const activeQuestionCount = $questionnaire.questions.filter((question) => !question.deletedAt).length;
|
||||
const answeredCount = Object.values($questionnaireMetaData.answers).filter((answer) =>
|
||||
hasAnswerValue(answer)
|
||||
).length;
|
||||
return Math.round((answeredCount / activeQuestionCount) * 100) || 0;
|
||||
}
|
||||
|
||||
return Math.round(((currentQuestionIndex - 1) / $questionnaire.questions.length) * 100) || 0;
|
||||
}
|
||||
|
||||
@@ -233,6 +305,48 @@
|
||||
return { fileKey: uploadResult.fileKey, fileName: file.name, fileUrl };
|
||||
}
|
||||
|
||||
const handleVideoRecordingUpload: ExerciseQuestionVideoRecordingUploader = async (input) => {
|
||||
if (!courseApi.course?.id || !exerciseId) {
|
||||
throw new Error('Exercise is not ready for upload');
|
||||
}
|
||||
|
||||
const initResult = await exerciseApi.initVideoRecordingUpload(courseApi.course.id, exerciseId, input.questionId, {
|
||||
fileName: input.fileName,
|
||||
mimeType: input.mimeType as 'video/webm' | 'video/mp4' | 'video/quicktime',
|
||||
size: input.size
|
||||
});
|
||||
if (!initResult?.data) {
|
||||
throw new Error('Failed to get upload URL');
|
||||
}
|
||||
|
||||
await axios.put(initResult.data.uploadUrl, input.blob, {
|
||||
headers: { 'Content-Type': input.mimeType },
|
||||
maxContentLength: Infinity,
|
||||
maxBodyLength: Infinity
|
||||
});
|
||||
|
||||
const completeResult = await exerciseApi.completeVideoRecordingUpload(
|
||||
courseApi.course.id,
|
||||
exerciseId,
|
||||
input.questionId,
|
||||
{
|
||||
assetId: initResult.data.assetId,
|
||||
storageKey: initResult.data.storageKey,
|
||||
fileName: input.fileName,
|
||||
mimeType: input.mimeType as 'video/webm' | 'video/mp4' | 'video/quicktime',
|
||||
size: input.size,
|
||||
durationSeconds: input.durationSeconds,
|
||||
recordedAt: input.recordedAt,
|
||||
retakeCount: input.retakeCount
|
||||
}
|
||||
);
|
||||
if (!completeResult?.data) {
|
||||
throw new Error('Failed to complete upload');
|
||||
}
|
||||
|
||||
return completeResult.data;
|
||||
};
|
||||
|
||||
function hasAnswerValue(answerValue: AnswerData | null | undefined): boolean {
|
||||
if (answerValue === null || answerValue === undefined) return false;
|
||||
if (typeof answerValue === 'object' && 'type' in answerValue) {
|
||||
@@ -256,6 +370,8 @@
|
||||
}
|
||||
case 'FILE_UPLOAD':
|
||||
return !!answerValue.fileKey;
|
||||
case 'VIDEO_RECORDING':
|
||||
return !!answerValue.assetId && !!answerValue.storageKey;
|
||||
case 'MATCHING':
|
||||
return answerValue.pairs.length > 0;
|
||||
case 'ORDERING':
|
||||
@@ -271,16 +387,22 @@
|
||||
return false;
|
||||
}
|
||||
|
||||
function onSharedAnswerChange(answerValue) {
|
||||
function onSharedAnswerChange(answerValue: AnswerData | null) {
|
||||
if (!sharedCurrentQuestionKey) return;
|
||||
|
||||
questionnaireMetaData.update((metaData) => ({
|
||||
...metaData,
|
||||
answers: {
|
||||
...metaData.answers,
|
||||
[sharedCurrentQuestionKey]: answerValue
|
||||
questionnaireMetaData.update((metaData) => {
|
||||
const answers = { ...metaData.answers };
|
||||
if (answerValue === null) {
|
||||
delete answers[sharedCurrentQuestionKey];
|
||||
} else {
|
||||
answers[sharedCurrentQuestionKey] = answerValue;
|
||||
}
|
||||
}));
|
||||
|
||||
return {
|
||||
...metaData,
|
||||
answers
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function onSharedNext(valueOverride?: AnswerData) {
|
||||
@@ -300,7 +422,59 @@
|
||||
onSubmit(sharedCurrentQuestionKey, valueToUse);
|
||||
}
|
||||
|
||||
function beginCurrentSection() {
|
||||
questionnaireMetaData.update((m) => ({
|
||||
...m,
|
||||
currentQuestionIndex: 1,
|
||||
sectionPhase: 'questions'
|
||||
}));
|
||||
}
|
||||
|
||||
function goBackFromSectionOverview() {
|
||||
if ($questionnaireMetaData.currentSectionIndex <= 0) {
|
||||
questionnaireMetaData.update((m) => ({
|
||||
...m,
|
||||
currentQuestionIndex: 0,
|
||||
sectionPhase: 'overview'
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
questionnaireMetaData.update((m) => ({
|
||||
...m,
|
||||
currentSectionIndex: m.currentSectionIndex - 1,
|
||||
currentQuestionIndex: 1,
|
||||
sectionPhase: 'overview'
|
||||
}));
|
||||
}
|
||||
|
||||
function onSectionQuestionAnswerChange(question: Question, answerValue: AnswerData) {
|
||||
const questionKey = getExerciseQuestionContractKey(toExerciseQuestionModel(question));
|
||||
questionnaireMetaData.update((metaData) => ({
|
||||
...metaData,
|
||||
answers: {
|
||||
...metaData.answers,
|
||||
[questionKey]: answerValue
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
function completeAllQuestionsSection() {
|
||||
const missingAnswer = currentSectionQuestions.find((question) => {
|
||||
const questionKey = getExerciseQuestionContractKey(toExerciseQuestionModel(question));
|
||||
return !hasAnswerValue($questionnaireMetaData.answers[questionKey]);
|
||||
});
|
||||
|
||||
if (missingAnswer) {
|
||||
snackbar.error(t.get('course.navItem.lessons.exercises.all_exercises.view_mode.answer_required'));
|
||||
return;
|
||||
}
|
||||
|
||||
evaluateSectionAfterBehavior();
|
||||
}
|
||||
|
||||
function handleEnterKey(e: KeyboardEvent) {
|
||||
if (hasSectionedExercise && $questionnaire.sectionDisplayMode === 'all_questions') return;
|
||||
if (e.key !== 'Enter' || isSubmitting || !currentQuestion) return;
|
||||
|
||||
const target = e.target as HTMLElement;
|
||||
@@ -374,9 +548,34 @@
|
||||
});
|
||||
|
||||
const progressValue = $derived(getProgressValue($questionnaireMetaData.currentQuestionIndex));
|
||||
const currentQuestion = $derived($questionnaire.questions[$questionnaireMetaData.currentQuestionIndex - 1]);
|
||||
const activeSections = $derived(
|
||||
[...($questionnaire.sections ?? [])].filter((section) => !section.deletedAt).sort((a, b) => a.order - b.order)
|
||||
);
|
||||
const hasSectionedExercise = $derived(hasSections($questionnaire.sections ?? []));
|
||||
const currentSection = $derived(
|
||||
hasSectionedExercise ? activeSections[$questionnaireMetaData.currentSectionIndex] : null
|
||||
);
|
||||
const currentSectionQuestions = $derived(
|
||||
currentSection ? getQuestionsForSection($questionnaire.questions, currentSection.id) : []
|
||||
);
|
||||
const currentSectionDisplayTitle = $derived(
|
||||
currentSection
|
||||
? getExerciseSectionDisplayTitle({
|
||||
title: currentSection.title,
|
||||
sectionNumber: $questionnaireMetaData.currentSectionIndex + 1,
|
||||
sectionLabel: sectionFallbackTitle
|
||||
})
|
||||
: ''
|
||||
);
|
||||
const currentQuestionList = $derived(hasSectionedExercise ? currentSectionQuestions : $questionnaire.questions);
|
||||
const currentQuestion = $derived(currentQuestionList[$questionnaireMetaData.currentQuestionIndex - 1]);
|
||||
const isExerciseLoaded = $derived(alreadyCheckedAutoSavedData && $questionnaire.questions.length > 0);
|
||||
const isFinished = $derived(isExerciseLoaded && $questionnaireMetaData.currentQuestionIndex > 0 && !currentQuestion);
|
||||
const isFinished = $derived(
|
||||
isExerciseLoaded &&
|
||||
$questionnaireMetaData.currentQuestionIndex > 0 &&
|
||||
!currentQuestion &&
|
||||
(!hasSectionedExercise || $questionnaireMetaData.isFinished)
|
||||
);
|
||||
const sharedQuestionModel = $derived(currentQuestion ? toExerciseQuestionModel(currentQuestion) : null);
|
||||
const sharedCurrentQuestionKey = $derived(
|
||||
sharedQuestionModel ? getExerciseQuestionContractKey(sharedQuestionModel) : null
|
||||
@@ -385,6 +584,33 @@
|
||||
sharedCurrentQuestionKey ? $questionnaireMetaData.answers[sharedCurrentQuestionKey] : undefined
|
||||
);
|
||||
const canGoNextForSharedQuestion = $derived(hasAnswerValue(sharedCurrentAnswer));
|
||||
const sectionQuestionTotalPoints = $derived(getTotalPossibleGrade(currentSectionQuestions));
|
||||
const sectionNavigationGroups = $derived(
|
||||
activeSections.map((section, sectionIndex) => {
|
||||
const sectionQuestions = getQuestionsForSection($questionnaire.questions, section.id);
|
||||
return {
|
||||
id: section.id,
|
||||
title: getExerciseSectionDisplayTitle({
|
||||
title: section.title,
|
||||
sectionNumber: sectionIndex + 1,
|
||||
sectionLabel: sectionFallbackTitle
|
||||
}),
|
||||
isCurrent: currentSection?.id === section.id,
|
||||
questions: sectionQuestions.map((question, questionIndex) => {
|
||||
const questionKey = getExerciseQuestionContractKey(toExerciseQuestionModel(question));
|
||||
return {
|
||||
key: questionKey,
|
||||
label: `Q${questionIndex + 1}`,
|
||||
isAnswered: hasAnswerValue($questionnaireMetaData.answers[questionKey]),
|
||||
isCurrent:
|
||||
sectionIndex === $questionnaireMetaData.currentSectionIndex &&
|
||||
questionIndex === $questionnaireMetaData.currentQuestionIndex - 1 &&
|
||||
$questionnaireMetaData.sectionPhase === 'questions'
|
||||
};
|
||||
})
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const flyX = $derived(slideDirection === 'next' ? 300 : -300);
|
||||
const flyOutX = $derived(-flyX);
|
||||
@@ -459,6 +685,8 @@
|
||||
answers,
|
||||
totalPossibleGrade,
|
||||
currentQuestionIndex: $questionnaire.questions.length,
|
||||
currentSectionIndex: activeSections.length > 0 ? activeSections.length - 1 : 0,
|
||||
sectionPhase: 'questions',
|
||||
isFinished: true,
|
||||
status: submission.statusId ?? STATUS.PENDING,
|
||||
finalTotalGrade,
|
||||
@@ -470,7 +698,7 @@
|
||||
|
||||
$effect(() => {
|
||||
if (prevExerciseId === exerciseId) return;
|
||||
if (!!prevExerciseId) {
|
||||
if (prevExerciseId) {
|
||||
isSubmitting = false;
|
||||
alreadyCheckedAutoSavedData = false;
|
||||
skipHydrateFromSubmissions = false;
|
||||
@@ -483,7 +711,12 @@
|
||||
{#if !preview && $questionnaire.questions.length && !$questionnaireMetaData.isFinished && $questionnaireMetaData.currentQuestionIndex > 0}
|
||||
<div class="mb-6 flex min-w-0 items-center gap-3">
|
||||
<span class="ui:text-muted-foreground shrink-0 text-sm tabular-nums">
|
||||
{$questionnaireMetaData.currentQuestionIndex}/{$questionnaire.questions.length}
|
||||
{#if hasSectionedExercise}
|
||||
{Object.values($questionnaireMetaData.answers).filter((answer) => hasAnswerValue(answer))
|
||||
.length}/{$questionnaire.questions.length}
|
||||
{:else}
|
||||
{$questionnaireMetaData.currentQuestionIndex}/{$questionnaire.questions.length}
|
||||
{/if}
|
||||
</span>
|
||||
<Progress class="min-w-0 flex-1" value={$questionnaireMetaData.progressValue} />
|
||||
</div>
|
||||
@@ -493,7 +726,11 @@
|
||||
|
||||
{#if preview}
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<Preview questions={filterOutDeleted($questionnaire.questions)} questionnaireMetaData={$questionnaireMetaData} />
|
||||
<Preview
|
||||
questions={filterOutDeleted($questionnaire.questions)}
|
||||
sections={$questionnaire.sections}
|
||||
questionnaireMetaData={$questionnaireMetaData}
|
||||
/>
|
||||
</RoleBasedSecurity>
|
||||
{:else if !$questionnaire.questions.length}
|
||||
<Empty
|
||||
@@ -621,6 +858,7 @@
|
||||
questions={[...$questionnaire.questions].sort(
|
||||
(leftQuestion, rightQuestion) => leftQuestion.order - rightQuestion.order
|
||||
)}
|
||||
sections={$questionnaire.sections}
|
||||
questionnaireMetaData={$questionnaireMetaData}
|
||||
grades={$questionnaireMetaData.grades}
|
||||
disableGrading={true}
|
||||
@@ -636,44 +874,133 @@
|
||||
{/if}
|
||||
</RoleBasedSecurity>
|
||||
{/if}
|
||||
{:else if currentQuestion && currentQuestion?.id}
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="grid overflow-hidden" style="grid-template-areas: 'slot';">
|
||||
{#key currentQuestion.id}
|
||||
<div
|
||||
id="question"
|
||||
class="min-w-0 [grid-area:slot]"
|
||||
in:fly={{ x: flyX, duration: 350, easing: cubicInOut }}
|
||||
out:fly={{ x: flyOutX, duration: 350, easing: cubicInOut }}
|
||||
>
|
||||
{#if sharedQuestionModel}
|
||||
<ExerciseQuestion.QuestionRenderer
|
||||
contract={{
|
||||
mode: 'take',
|
||||
question: sharedQuestionModel,
|
||||
answer: sharedCurrentAnswer,
|
||||
labels: questionLabels,
|
||||
disabled: isSubmitting,
|
||||
onFileUpload: handleFileUpload
|
||||
}}
|
||||
onAnswerChange={onSharedAnswerChange}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
</div>
|
||||
<div>
|
||||
<ExerciseQuestion.QuestionNavigation
|
||||
canGoBack={$questionnaireMetaData.currentQuestionIndex > 1}
|
||||
canGoNext={canGoNextForSharedQuestion && !isSubmitting}
|
||||
isLast={$questionnaireMetaData.currentQuestionIndex === $questionnaire.questions.length}
|
||||
{isSubmitting}
|
||||
previousLabel={t.get('course.navItem.lessons.exercises.all_exercises.previous')}
|
||||
nextLabel={t.get('course.navItem.lessons.exercises.all_exercises.next')}
|
||||
finishLabel={t.get('course.navItem.lessons.exercises.all_exercises.finish')}
|
||||
{onPrevious}
|
||||
onNext={onSharedNext}
|
||||
{:else if hasSectionedExercise && currentSection && $questionnaireMetaData.sectionPhase === 'overview'}
|
||||
<ExerciseQuestion.SectionOverview
|
||||
sectionTitle={currentSectionDisplayTitle}
|
||||
sectionDescription={currentSection.description}
|
||||
sectionNumber={$questionnaireMetaData.currentSectionIndex + 1}
|
||||
totalSections={activeSections.length}
|
||||
questionCount={currentSectionQuestions.length}
|
||||
totalPoints={sectionQuestionTotalPoints}
|
||||
colorTheme={currentSection.colorTheme}
|
||||
onBegin={beginCurrentSection}
|
||||
onBack={goBackFromSectionOverview}
|
||||
labels={{
|
||||
beginSection: $t('course.navItem.lessons.exercises.all_exercises.view_mode.begin_section'),
|
||||
back: $t('course.navItem.lessons.exercises.all_exercises.view_mode.back'),
|
||||
questions: $t('course.navItem.lessons.exercises.all_exercises.view_mode.questions'),
|
||||
points: $t('course.navItem.lessons.exercises.all_exercises.view_mode.points'),
|
||||
section: $t('course.navItem.lessons.exercises.all_exercises.section.fallback_title')
|
||||
}}
|
||||
/>
|
||||
{:else if hasSectionedExercise && currentSection && $questionnaire.sectionDisplayMode === 'all_questions' && $questionnaireMetaData.sectionPhase === 'questions'}
|
||||
<div class="grid gap-4 lg:grid-cols-[180px_minmax(0,1fr)]">
|
||||
<ExerciseQuestion.SectionNavigationSidebar sections={sectionNavigationGroups} />
|
||||
<section class="space-y-4">
|
||||
<ExerciseQuestion.SectionHeader
|
||||
title={currentSectionDisplayTitle}
|
||||
description={currentSection.description}
|
||||
sectionNumber={$questionnaireMetaData.currentSectionIndex + 1}
|
||||
totalSections={activeSections.length}
|
||||
colorTheme={currentSection.colorTheme}
|
||||
questionCount={currentSectionQuestions.length}
|
||||
totalPoints={sectionQuestionTotalPoints}
|
||||
labels={{
|
||||
section: $t('course.navItem.lessons.exercises.all_exercises.section.fallback_title'),
|
||||
questions: $t('course.navItem.lessons.exercises.all_exercises.view_mode.questions'),
|
||||
points: $t('course.navItem.lessons.exercises.all_exercises.view_mode.points')
|
||||
}}
|
||||
/>
|
||||
|
||||
{#each currentSectionQuestions as sectionQuestion, sectionQuestionIndex (sectionQuestion.id)}
|
||||
{@const sectionQuestionModel = toExerciseQuestionModel(sectionQuestion)}
|
||||
{@const sectionQuestionKey = getExerciseQuestionContractKey(toExerciseQuestionModel(sectionQuestion))}
|
||||
<ExerciseQuestion.QuestionRenderer
|
||||
contract={{
|
||||
mode: 'take',
|
||||
question: sectionQuestionModel,
|
||||
answer: $questionnaireMetaData.answers[sectionQuestionKey],
|
||||
labels: questionLabels,
|
||||
disabled: isSubmitting,
|
||||
onFileUpload: handleFileUpload,
|
||||
onVideoRecordingUpload: handleVideoRecordingUpload
|
||||
}}
|
||||
questionNumber={sectionQuestionIndex + 1}
|
||||
questionNumberActive={false}
|
||||
onAnswerChange={(answerValue) => onSectionQuestionAnswerChange(sectionQuestion, answerValue)}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button type="button" onclick={completeAllQuestionsSection} disabled={isSubmitting} loading={isSubmitting}>
|
||||
{$questionnaireMetaData.currentSectionIndex === activeSections.length - 1
|
||||
? $t('course.navItem.lessons.exercises.all_exercises.finish')
|
||||
: $t('course.navItem.lessons.exercises.all_exercises.view_mode.complete_section')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{:else if currentQuestion && currentQuestion?.id}
|
||||
<div class={hasSectionedExercise ? 'grid gap-4 lg:grid-cols-[180px_minmax(0,1fr)]' : ''}>
|
||||
{#if hasSectionedExercise}
|
||||
<ExerciseQuestion.SectionNavigationSidebar sections={sectionNavigationGroups} />
|
||||
{/if}
|
||||
<div class="flex min-w-0 flex-col gap-4">
|
||||
{#if hasSectionedExercise && currentSection}
|
||||
<ExerciseQuestion.SectionHeader
|
||||
title={currentSectionDisplayTitle}
|
||||
description={currentSection.description}
|
||||
sectionNumber={$questionnaireMetaData.currentSectionIndex + 1}
|
||||
totalSections={activeSections.length}
|
||||
colorTheme={currentSection.colorTheme}
|
||||
questionCount={currentSectionQuestions.length}
|
||||
totalPoints={sectionQuestionTotalPoints}
|
||||
labels={{
|
||||
section: $t('course.navItem.lessons.exercises.all_exercises.section.fallback_title'),
|
||||
questions: $t('course.navItem.lessons.exercises.all_exercises.view_mode.questions'),
|
||||
points: $t('course.navItem.lessons.exercises.all_exercises.view_mode.points')
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<div class="grid overflow-hidden" style="grid-template-areas: 'slot';">
|
||||
{#key currentQuestion.id}
|
||||
<div
|
||||
id="question"
|
||||
class="min-w-0 [grid-area:slot]"
|
||||
in:fly={{ x: flyX, duration: 350, easing: cubicInOut }}
|
||||
out:fly={{ x: flyOutX, duration: 350, easing: cubicInOut }}
|
||||
>
|
||||
{#if sharedQuestionModel}
|
||||
<ExerciseQuestion.QuestionRenderer
|
||||
contract={{
|
||||
mode: 'take',
|
||||
question: sharedQuestionModel,
|
||||
answer: sharedCurrentAnswer,
|
||||
labels: questionLabels,
|
||||
disabled: isSubmitting,
|
||||
onFileUpload: handleFileUpload,
|
||||
onVideoRecordingUpload: handleVideoRecordingUpload
|
||||
}}
|
||||
questionNumber={$questionnaireMetaData.currentQuestionIndex}
|
||||
onAnswerChange={onSharedAnswerChange}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
</div>
|
||||
<div>
|
||||
<ExerciseQuestion.QuestionNavigation
|
||||
canGoBack={hasSectionedExercise || $questionnaireMetaData.currentQuestionIndex > 1}
|
||||
canGoNext={canGoNextForSharedQuestion && !isSubmitting}
|
||||
isLast={$questionnaireMetaData.currentQuestionIndex === currentQuestionList.length}
|
||||
{isSubmitting}
|
||||
previousLabel={t.get('course.navItem.lessons.exercises.all_exercises.previous')}
|
||||
nextLabel={t.get('course.navItem.lessons.exercises.all_exercises.next')}
|
||||
finishLabel={t.get('course.navItem.lessons.exercises.all_exercises.finish')}
|
||||
{onPrevious}
|
||||
onNext={onSharedNext}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
+27
-4
@@ -11,6 +11,7 @@
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { RoleBasedSecurity } from '$features/ui';
|
||||
import DeleteLessonConfirmation from '$features/course/components/lesson/delete-lesson-confirmation.svelte';
|
||||
import { slugifyTitle } from '@cio/utils/validation';
|
||||
|
||||
interface Props {
|
||||
mode: (typeof MODES)[keyof typeof MODES];
|
||||
@@ -20,6 +21,10 @@
|
||||
onTitleChange: (value: string) => void;
|
||||
onToggleLock: () => void;
|
||||
onDeleteLesson: () => Promise<void> | void;
|
||||
slug?: string;
|
||||
showSlugEditor?: boolean;
|
||||
slugError?: string;
|
||||
onSlugChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -29,9 +34,15 @@
|
||||
isDeletingLesson = false,
|
||||
onTitleChange,
|
||||
onToggleLock,
|
||||
onDeleteLesson
|
||||
onDeleteLesson,
|
||||
slug = '',
|
||||
showSlugEditor = false,
|
||||
slugError,
|
||||
onSlugChange = () => {}
|
||||
}: Props = $props();
|
||||
|
||||
// Slug field: commented out via `{#if false && ...}` below — drop `false &&` to show again.
|
||||
|
||||
let openDeleteModal = $state(false);
|
||||
|
||||
const lockLabel = $derived(
|
||||
@@ -40,7 +51,7 @@
|
||||
</script>
|
||||
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="min-w-0 flex-1 space-y-2">
|
||||
{#if mode === MODES.edit && !$isOrgStudent}
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<InputField
|
||||
@@ -49,6 +60,17 @@
|
||||
placeholder={$t('course.navItem.lessons.add_lesson.lesson_title')}
|
||||
onInputChange={(e) => onTitleChange(e.currentTarget.value)}
|
||||
/>
|
||||
{#if false && showSlugEditor}
|
||||
<InputField
|
||||
className="max-w-xl"
|
||||
label={$t('course.navItem.settings.slug.label')}
|
||||
helperMessage={$t('course.navItem.settings.slug.description')}
|
||||
value={slug}
|
||||
placeholder={slugifyTitle(title || '')}
|
||||
onInputChange={(e) => onSlugChange(e.currentTarget.value)}
|
||||
errorMessage={slugError}
|
||||
/>
|
||||
{/if}
|
||||
</RoleBasedSecurity>
|
||||
{:else}
|
||||
<Page.Title>{title}</Page.Title>
|
||||
@@ -69,8 +91,9 @@
|
||||
<IconButton
|
||||
onclick={() => (openDeleteModal = true)}
|
||||
disabled={isDeletingLesson}
|
||||
aria-label={$t('course.navItem.lessons.add_lesson.delete')}
|
||||
title={$t('course.navItem.lessons.add_lesson.delete')}
|
||||
aria-label={$t('course.navItem.lessons.add_lesson.delete_tooltip')}
|
||||
tooltip={$t('course.navItem.lessons.add_lesson.delete_tooltip')}
|
||||
tooltipSide="bottom"
|
||||
>
|
||||
{#if isDeletingLesson}
|
||||
<Spinner class="h-4 w-4" />
|
||||
|
||||
@@ -17,13 +17,6 @@
|
||||
let step = $state(0);
|
||||
|
||||
const options = [
|
||||
{
|
||||
id: 'live-class',
|
||||
title: $t('new_course_modal.live_class_label'),
|
||||
subtitle: $t('new_course_modal.live_class_subtitle'),
|
||||
type: 'LIVE_CLASS' as TCourseType,
|
||||
isDisabled: false
|
||||
},
|
||||
{
|
||||
id: 'self-paced',
|
||||
title: $t('new_course_modal.self_paced_label'),
|
||||
@@ -31,12 +24,26 @@
|
||||
type: 'SELF_PACED' as TCourseType,
|
||||
isDisabled: false
|
||||
},
|
||||
{
|
||||
id: 'live-class',
|
||||
title: $t('new_course_modal.live_class_label'),
|
||||
subtitle: $t('new_course_modal.live_class_subtitle'),
|
||||
type: 'LIVE_CLASS' as TCourseType,
|
||||
isDisabled: false
|
||||
},
|
||||
{
|
||||
id: 'compliance',
|
||||
title: $t('new_course_modal.compliance_label'),
|
||||
subtitle: $t('new_course_modal.compliance_subtitle'),
|
||||
type: 'COMPLIANCE' as TCourseType,
|
||||
isDisabled: false
|
||||
},
|
||||
{
|
||||
id: 'public',
|
||||
title: $t('new_course_modal.public_label'),
|
||||
subtitle: $t('new_course_modal.public_subtitle'),
|
||||
type: 'PUBLIC' as TCourseType,
|
||||
isDisabled: false
|
||||
}
|
||||
];
|
||||
const courseTypeOptionsForGroup = options.map((o) => ({
|
||||
@@ -75,7 +82,7 @@
|
||||
{#snippet course_type_selector()}
|
||||
<Field.Description>{$t('courses.new_course_modal.type_selector_title')}</Field.Description>
|
||||
|
||||
<RadioOptionCardGroup options={courseTypeOptionsForGroup} bind:value={type} class="grid-cols-1 md:grid-cols-3" />
|
||||
<RadioOptionCardGroup options={courseTypeOptionsForGroup} bind:value={type} class="grid-cols-1 md:grid-cols-2" />
|
||||
{/snippet}
|
||||
|
||||
<Dialog.Root
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
/** Extra hint under points row (e.g. auto-grade requires non-zero points) */
|
||||
pointsHint?: string | null;
|
||||
onPointsChange?: any;
|
||||
elementId?: string;
|
||||
key?: string;
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
@@ -27,6 +28,7 @@
|
||||
errorMsg = null,
|
||||
pointsHint = null,
|
||||
onPointsChange = () => {},
|
||||
elementId,
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
@@ -44,6 +46,7 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
id={elementId}
|
||||
bind:this={ref}
|
||||
class="border-border border bg-white dark:bg-black {hasError ? 'border-red-700' : ''} root relative mb-6 rounded-md"
|
||||
>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
|
||||
import EyeIcon from '@lucide/svelte/icons/eye';
|
||||
import PencilIcon from '@lucide/svelte/icons/pencil';
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import { onDestroy, onMount, tick, untrack } from 'svelte';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import CirclePlusIcon from '@lucide/svelte/icons/circle-plus';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
@@ -14,7 +14,11 @@
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
|
||||
import {
|
||||
type ExerciseEditorErrors,
|
||||
handleAddQuestion,
|
||||
handleAddSection,
|
||||
getQuestionsForSection,
|
||||
hasSections,
|
||||
questionnaire,
|
||||
questionnaireOrder,
|
||||
reset,
|
||||
@@ -36,21 +40,28 @@
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import UpdateDescription from '$features/course/components/exercise/update-description.svelte';
|
||||
import { ContentNavigationActions } from '$features/course/components/lesson';
|
||||
import { RefreshPageData, RoleBasedSecurity } from '$features/ui';
|
||||
import {
|
||||
// RefreshPageData,
|
||||
RoleBasedSecurity,
|
||||
UnsavedChanges
|
||||
} from '$features/ui';
|
||||
import { isSelfPacedLikeCourse } from '$features/course/utils/compliance-utils';
|
||||
import { getOrderedNavigableContent } from '$features/course/utils/content';
|
||||
import { refreshExercisePageData } from '$features/course/utils/exercise-page-utils';
|
||||
import {
|
||||
hydrateExercisePageData
|
||||
// , refreshExercisePageData
|
||||
} from '$features/course/utils/exercise-page-utils';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import VideoIcon from '@lucide/svelte/icons/video';
|
||||
import {
|
||||
getQuestionTypeId,
|
||||
getQuestionTypeKeyFromId,
|
||||
getQuestionTypeOptionById
|
||||
getQuestionTypeKeyFromId
|
||||
} from '$features/course/components/exercise/question-type-utils';
|
||||
import type { SubmissionListItem } from '$features/course/utils/types';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { isAutoGradableQuestionType } from '@cio/question-types';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import type { ExerciseSectionState } from '$features/course/components/exercise/store';
|
||||
|
||||
interface Props {
|
||||
exerciseId?: string;
|
||||
@@ -85,7 +96,32 @@
|
||||
let shouldDeleteExercise = $state(false);
|
||||
let isSaving = $state(false);
|
||||
let isDeleting = $state(false);
|
||||
let reorderQuestions = $state(false);
|
||||
let selectedTab = $state<ExerciseTab>('questions');
|
||||
// eslint-disable-next-line svelte/prefer-writable-derived -- must be writable: cleared before intentional navigations and bound to UnsavedChanges
|
||||
let hasUnsavedChanges = $state(false);
|
||||
|
||||
function isTemporaryId(id: string | number | undefined) {
|
||||
return typeof id === 'string' && id.includes('-form');
|
||||
}
|
||||
|
||||
function hasDirtyQuestionnaire() {
|
||||
const hasDirtyQuestion = ($questionnaire.questions ?? []).some((question) => {
|
||||
const hasDirtyOption = (question.options ?? []).some((option) => option.isDirty || isTemporaryId(option.id));
|
||||
return question.isDirty || Boolean(question.deletedAt) || isTemporaryId(question.id) || hasDirtyOption;
|
||||
});
|
||||
const hasDirtySection = ($questionnaire.sections ?? []).some(
|
||||
(section) => section.isDirty || Boolean(section.deletedAt)
|
||||
);
|
||||
|
||||
return Boolean(
|
||||
$questionnaire.isTitleDirty ||
|
||||
$questionnaire.isDescriptionDirty ||
|
||||
$questionnaire.isDueByDirty ||
|
||||
hasDirtyQuestion ||
|
||||
hasDirtySection
|
||||
);
|
||||
}
|
||||
|
||||
async function handleDeleteExercise() {
|
||||
if (!courseApi.course?.id) return;
|
||||
@@ -94,6 +130,7 @@
|
||||
await exerciseApi.delete(courseApi.course.id, exerciseId);
|
||||
|
||||
if (exerciseApi.success) {
|
||||
hasUnsavedChanges = false;
|
||||
courseApi.removeContentItem(exerciseId, ContentType.Exercise);
|
||||
goBack();
|
||||
}
|
||||
@@ -111,6 +148,25 @@
|
||||
});
|
||||
}
|
||||
|
||||
function getSectionValidationErrors(sections: ExerciseSectionState[]): ExerciseEditorErrors {
|
||||
const sectionErrors: ExerciseEditorErrors = {};
|
||||
const requiredFieldMessage = t.get('validations.generic.required_field');
|
||||
|
||||
for (const section of sections) {
|
||||
const nextSectionErrors: ExerciseEditorErrors[string] = {};
|
||||
|
||||
if (!section.title?.trim()) {
|
||||
nextSectionErrors.title = requiredFieldMessage;
|
||||
}
|
||||
|
||||
if (Object.keys(nextSectionErrors).length === 0) continue;
|
||||
|
||||
sectionErrors[section.id] = nextSectionErrors;
|
||||
}
|
||||
|
||||
return sectionErrors;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if ($isOrgStudent || !courseApi.course?.id) return;
|
||||
|
||||
@@ -119,16 +175,33 @@
|
||||
const transformedQuestions = transformQuestionsToApiFormat($questionnaire.questions, {
|
||||
shouldFilterEmptyLabels: false
|
||||
});
|
||||
const activeSections = $questionnaire.sections.filter((section) => !section.deletedAt);
|
||||
const sectionsPayload = activeSections.map((section) => ({
|
||||
id: section.id,
|
||||
title: section.title,
|
||||
description: section.description,
|
||||
order: section.order,
|
||||
colorTheme: section.colorTheme,
|
||||
afterBehavior: section.afterBehavior,
|
||||
questionIds: getQuestionsForSection($questionnaire.questions, section.id).map((question) => question.id)
|
||||
}));
|
||||
const shouldSyncSections = $questionnaire.sections.length > 0 || hasSections($questionnaire.sections);
|
||||
const sectionValidationErrors = getSectionValidationErrors(activeSections);
|
||||
|
||||
// Validate using Zod schema
|
||||
const zodResult = ZExerciseUpdate.safeParse({
|
||||
questions: transformedQuestions
|
||||
questions: transformedQuestions,
|
||||
sections: shouldSyncSections ? sectionsPayload : undefined,
|
||||
sectionDisplayMode: $questionnaire.sectionDisplayMode
|
||||
});
|
||||
|
||||
if (!zodResult.success) {
|
||||
const zodErrors = mapZodErrorsToTranslations(zodResult.error);
|
||||
if (!zodResult.success || Object.keys(sectionValidationErrors).length > 0) {
|
||||
const zodErrors = !zodResult.success ? mapZodErrorsToTranslations(zodResult.error) : {};
|
||||
const questionErrors = mapZodErrorsToQuestionErrors(zodErrors, $questionnaire.questions);
|
||||
questionnaireValidation.set(questionErrors);
|
||||
questionnaireValidation.set({
|
||||
...questionErrors,
|
||||
...sectionValidationErrors
|
||||
});
|
||||
snackbar.error('Please fix all validation errors before saving');
|
||||
return;
|
||||
}
|
||||
@@ -167,7 +240,9 @@
|
||||
title: $questionnaire.title ?? '',
|
||||
description: $questionnaire.description ?? '',
|
||||
dueBy: $questionnaire.dueBy ?? '',
|
||||
questions: questions
|
||||
questions: questions,
|
||||
sections: shouldSyncSections ? sectionsPayload : undefined,
|
||||
sectionDisplayMode: $questionnaire.sectionDisplayMode
|
||||
});
|
||||
|
||||
// Check if there are validation errors from the API
|
||||
@@ -179,20 +254,10 @@
|
||||
}
|
||||
|
||||
if (exerciseApi.success) {
|
||||
questionnaire.update((q) => ({
|
||||
...q,
|
||||
isTitleDirty: false,
|
||||
isDescriptionDirty: false,
|
||||
questions:
|
||||
exerciseApi.exercise?.questions?.map((question) => {
|
||||
const questionType = getQuestionTypeOptionById(getQuestionTypeId(question));
|
||||
return {
|
||||
...question,
|
||||
questionTypeId: questionType.id,
|
||||
questionType
|
||||
};
|
||||
}) || q.questions
|
||||
}));
|
||||
if (exerciseApi.exercise) {
|
||||
hydrateExercisePageData(exerciseApi.exercise, exerciseId);
|
||||
}
|
||||
hasUnsavedChanges = false;
|
||||
patchExerciseListItemLocally();
|
||||
snackbar.success('snackbar.exercise.success');
|
||||
}
|
||||
@@ -216,6 +281,17 @@
|
||||
selectedTab = nextTab;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
hasUnsavedChanges = !$isOrgStudent && hasDirtyQuestionnaire();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const hasActiveSections = hasSections($questionnaire.sections);
|
||||
if (!hasActiveSections && reorderQuestions) {
|
||||
reorderQuestions = false;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const currentTab = page.url.searchParams.get('tab') ?? '';
|
||||
// Prevent self-navigation loops: only update URL when it actually changes.
|
||||
@@ -250,6 +326,7 @@
|
||||
const isExerciseUnlocked = $derived(exerciseContentItem?.isUnlocked ?? false);
|
||||
|
||||
const isSelfPacedCourse = $derived(isSelfPacedLikeCourse(courseApi.course?.type));
|
||||
const isPublicCourse = $derived(courseApi.course?.type === 'PUBLIC');
|
||||
const activeQuestionTypeIds = $derived(
|
||||
($questionnaire.questions ?? []).filter((q) => !q.deletedAt).map((q) => getQuestionTypeId(q))
|
||||
);
|
||||
@@ -265,11 +342,42 @@
|
||||
if ($isOrgStudent || !isSelfPacedCourse || activeQuestionTypeIds.length === 0) return null;
|
||||
return isExerciseFullyAutoGradable(activeQuestionTypeIds) ? ('auto' as const) : ('manual' as const);
|
||||
});
|
||||
|
||||
async function scrollToExerciseElement(elementId: string) {
|
||||
await tick();
|
||||
requestAnimationFrame(() => {
|
||||
document.getElementById(elementId)?.scrollIntoView({
|
||||
block: 'start',
|
||||
behavior: 'smooth',
|
||||
inline: 'nearest'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getFirstActiveSectionId() {
|
||||
const [firstActiveSection] = [...($questionnaire.sections ?? [])]
|
||||
.filter((section) => !section.deletedAt)
|
||||
.sort((leftSection, rightSection) => leftSection.order - rightSection.order);
|
||||
|
||||
return firstActiveSection?.id ?? null;
|
||||
}
|
||||
|
||||
async function addQuestionFromHeader() {
|
||||
preview = false;
|
||||
const questionId = handleAddQuestion(getFirstActiveSectionId());
|
||||
await scrollToExerciseElement(`exercise-question-${questionId}`);
|
||||
}
|
||||
|
||||
async function addSectionFromHeader() {
|
||||
preview = false;
|
||||
const sectionId = handleAddSection();
|
||||
await scrollToExerciseElement(`exercise-section-${sectionId}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Page.Header isSticky={true} class="z-50! min-h-[36px]">
|
||||
<Page.Header isSticky={true} class="z-100! min-h-[36px]">
|
||||
<Page.HeaderContent>
|
||||
<Page.Title class="flex flex-wrap items-center gap-2">
|
||||
<Page.Title class="flex flex-col gap-2">
|
||||
<span>{$questionnaire.title || 'Exercise'}</span>
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
{#if teacherAutoGradeBadge === 'auto'}
|
||||
@@ -298,7 +406,10 @@
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.save')}
|
||||
</Button>
|
||||
<IconButton
|
||||
onclick={() => (preview = !preview)}
|
||||
onclick={() => {
|
||||
preview = !preview;
|
||||
reorderQuestions = false;
|
||||
}}
|
||||
tooltip={preview
|
||||
? $t('course.navItem.lessons.exercises.all_exercises.view_mode.edit')
|
||||
: $t('course.navItem.lessons.exercises.all_exercises.preview')}
|
||||
@@ -310,13 +421,22 @@
|
||||
<EyeIcon size={20} />
|
||||
{/if}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onclick={handleAddQuestion}
|
||||
tooltip={$t('course.navItem.lessons.exercises.all_exercises.add_question')}
|
||||
tooltipSide="bottom"
|
||||
>
|
||||
<CirclePlusIcon size={20} />
|
||||
</IconButton>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Button type="button" variant="ghost" size="icon" class="h-8 w-8">
|
||||
<CirclePlusIcon size={20} />
|
||||
<span class="sr-only">{$t('course.navItem.lessons.exercises.all_exercises.add')}</span>
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="center" class="z-201!">
|
||||
<DropdownMenu.Item onclick={addQuestionFromHeader}>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.add_question')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={addSectionFromHeader}>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.add_section')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8">
|
||||
@@ -325,10 +445,19 @@
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={() => ($questionnaireOrder.open = true)}>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.reorder')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
{#if hasSections($questionnaire.sections)}
|
||||
<DropdownMenu.Item onclick={() => (reorderQuestions = !reorderQuestions)}>
|
||||
{reorderQuestions
|
||||
? $t('course.navItem.lessons.exercises.all_exercises.reorder_done')
|
||||
: $t('course.navItem.lessons.exercises.all_exercises.reorder')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
{:else}
|
||||
<DropdownMenu.Item onclick={() => ($questionnaireOrder.open = true)}>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.reorder')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
{/if}
|
||||
<DropdownMenu.Item
|
||||
class="text-red-600 focus:text-red-600 dark:text-red-400"
|
||||
onclick={() => (shouldDeleteExercise = true)}
|
||||
@@ -341,7 +470,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<RefreshPageData onRefresh={() => refreshExercisePageData(courseApi.course?.id ?? '', exerciseId)} />
|
||||
<!-- <RefreshPageData onRefresh={() => refreshExercisePageData(courseApi.course?.id ?? '', exerciseId)} /> -->
|
||||
</RoleBasedSecurity>
|
||||
</div>
|
||||
</Page.Action>
|
||||
@@ -377,7 +506,7 @@
|
||||
<Badge variant="outline">{submissions.length}</Badge>
|
||||
</UnderlineTabs.Trigger>
|
||||
</UnderlineTabs.List>
|
||||
<UnderlineTabs.Content value="questions">
|
||||
<UnderlineTabs.Content value="questions" class="mx-auto w-full md:max-w-3xl">
|
||||
<UpdateDescription {preview} />
|
||||
{#if !preview}
|
||||
<EditMode
|
||||
@@ -385,6 +514,8 @@
|
||||
{goBack}
|
||||
{requiresPositivePointsForAutoGrade}
|
||||
selfPacedCourse={isSelfPacedCourse}
|
||||
{isPublicCourse}
|
||||
{reorderQuestions}
|
||||
/>
|
||||
{:else}
|
||||
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmissions} />
|
||||
@@ -430,3 +561,5 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
|
||||
<UnsavedChanges bind:hasUnsavedChanges />
|
||||
|
||||
@@ -73,6 +73,8 @@
|
||||
|
||||
const lessonTitle = $derived(lessonApi.lesson?.title || 'Lesson');
|
||||
const isLessonUnlocked = $derived(lessonApi.lesson?.isUnlocked ?? false);
|
||||
const lessonSlug = $derived(lessonApi.lesson?.slug ?? '');
|
||||
const isPublicCourse = $derived(courseApi.course?.type === 'PUBLIC');
|
||||
|
||||
function setModeQueryParam(value: (typeof MODES)[keyof typeof MODES]) {
|
||||
const params = new SvelteURLSearchParams($page.url.searchParams);
|
||||
@@ -202,7 +204,8 @@
|
||||
isUnlocked: lessonApi.lesson.isUnlocked ?? undefined,
|
||||
slideUrl: lessonApi.lesson.slideUrl || undefined,
|
||||
videos: lessonApi.lesson.videos || [],
|
||||
documents: lessonApi.lesson.documents || []
|
||||
documents: lessonApi.lesson.documents || [],
|
||||
slug: isPublicCourse && lessonApi.lesson.slug ? lessonApi.lesson.slug : undefined
|
||||
}),
|
||||
saveOrUpdateTranslation(lessonApi.currentLocale, lessonId)
|
||||
]);
|
||||
@@ -218,6 +221,10 @@
|
||||
lessonApi.updateLessonState('title', value);
|
||||
}
|
||||
|
||||
function handleLessonSlugChange(value: string) {
|
||||
lessonApi.updateLessonState('slug', value);
|
||||
}
|
||||
|
||||
function handleToggleLessonLock() {
|
||||
const currentValue = lessonApi.lesson?.isUnlocked ?? false;
|
||||
lessonApi.updateLessonState('isUnlocked', !currentValue);
|
||||
@@ -361,6 +368,10 @@
|
||||
onTitleChange={handleLessonTitleChange}
|
||||
onToggleLock={handleToggleLessonLock}
|
||||
onDeleteLesson={handleDeleteLesson}
|
||||
slug={lessonSlug}
|
||||
showSlugEditor={isPublicCourse}
|
||||
slugError={lessonApi.errors.slug}
|
||||
onSlugChange={handleLessonSlugChange}
|
||||
/>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import BookOpenIcon from '@lucide/svelte/icons/book-open';
|
||||
import ContentList from '$features/course/components/lesson/content-list.svelte';
|
||||
import ContentSectionList from '$features/course/components/lesson/content-section-list.svelte';
|
||||
import CourseContentIcon from '$features/course/components/course-content-icon.svelte';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { getCourseContent } from '$features/course/utils/content';
|
||||
@@ -28,6 +29,12 @@
|
||||
contentItems.filter((item) => item.type === ContentType.Lesson || item.type === ContentType.Exercise)
|
||||
);
|
||||
|
||||
const sectionsTotal = $derived(
|
||||
contentData.grouped ? contentData.sections.filter((section) => section.id !== 'ungrouped').length : 0
|
||||
);
|
||||
const lessonsTotal = $derived(contentItems.filter((item) => item.type === ContentType.Lesson).length);
|
||||
const exercisesTotal = $derived(contentItems.filter((item) => item.type === ContentType.Exercise).length);
|
||||
|
||||
let isFetching: boolean = $state(false);
|
||||
let hasHandledNext = $state(false);
|
||||
|
||||
@@ -65,6 +72,34 @@
|
||||
variant="page"
|
||||
/>
|
||||
{:else if contentLength > 0}
|
||||
<div
|
||||
class="mb-5 grid grid-cols-1 gap-3 sm:grid-cols-3"
|
||||
role="region"
|
||||
aria-label={t.get('course.navItem.lessons.heading_v2')}
|
||||
>
|
||||
<div class="ui:border-border flex flex-col gap-1 rounded-lg border px-4 py-3">
|
||||
<div class="ui:text-muted-foreground flex items-center gap-2 text-xs font-medium">
|
||||
<CourseContentIcon type={ContentType.Section} size={14} />
|
||||
<span>{$t('course.navItem.lessons.stats.sections')}</span>
|
||||
</div>
|
||||
<p class="text-2xl font-semibold tabular-nums">{sectionsTotal}</p>
|
||||
</div>
|
||||
<div class="ui:border-border flex flex-col gap-1 rounded-lg border px-4 py-3">
|
||||
<div class="ui:text-muted-foreground flex items-center gap-2 text-xs font-medium">
|
||||
<CourseContentIcon type={ContentType.Lesson} size={14} />
|
||||
<span>{$t('course.navItem.lessons.stats.lessons')}</span>
|
||||
</div>
|
||||
<p class="text-2xl font-semibold tabular-nums">{lessonsTotal}</p>
|
||||
</div>
|
||||
<div class="ui:border-border flex flex-col gap-1 rounded-lg border px-4 py-3">
|
||||
<div class="ui:text-muted-foreground flex items-center gap-2 text-xs font-medium">
|
||||
<CourseContentIcon type={ContentType.Exercise} size={14} />
|
||||
<span>{$t('course.navItem.lessons.stats.exercises')}</span>
|
||||
</div>
|
||||
<p class="text-2xl font-semibold tabular-nums">{exercisesTotal}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if reorder}
|
||||
<p class="text-center text-xs text-gray-400 italic dark:text-white">
|
||||
{$t('course.navItem.lessons.drag')}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import Empty from '@cio/ui/custom/empty/empty.svelte';
|
||||
import { BookIcon } from '@lucide/svelte';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { buildNextNewsfeedReaction } from '@cio/ui/custom/newsfeed-reactions';
|
||||
import { buildNextNewsfeedReaction, type NewsfeedReactionType } from '@cio/ui/custom/newsfeed-reactions';
|
||||
|
||||
interface Props {
|
||||
courseId: string;
|
||||
@@ -48,13 +48,13 @@
|
||||
await newsfeedApi.deleteComment(courseId, feedId, commentId);
|
||||
};
|
||||
|
||||
const addNewReaction = async (reactionType: string, feedId: string, authorId: string) => {
|
||||
const addNewReaction = async (reactionType: NewsfeedReactionType, feedId: string, authorId: string) => {
|
||||
if (!authorId || isReactingByFeedId[feedId]) return;
|
||||
|
||||
const reactedFeed = newsfeedApi.feeds.find((feed) => feed.id === feedId);
|
||||
if (!reactedFeed) return;
|
||||
|
||||
const updatedReaction = buildNextNewsfeedReaction(reactedFeed.reaction, reactionType, authorId);
|
||||
const updatedReaction = buildNextNewsfeedReaction(reactedFeed.reaction ?? undefined, reactionType, authorId);
|
||||
|
||||
isReactingByFeedId = { ...isReactingByFeedId, [feedId]: true };
|
||||
|
||||
|
||||
@@ -170,7 +170,8 @@
|
||||
metadata: metadataPayload,
|
||||
slug: courseApi.course.slug ?? undefined,
|
||||
compliance:
|
||||
$settings.type === 'COMPLIANCE' ? (courseApi.course.compliance ?? DEFAULT_COMPLIANCE_SETTINGS) : undefined
|
||||
$settings.type === 'COMPLIANCE' ? (courseApi.course.compliance ?? DEFAULT_COMPLIANCE_SETTINGS) : undefined,
|
||||
callout: $settings.type === 'PUBLIC' ? sanitizeCalloutForSave($settings.callout) : null
|
||||
};
|
||||
|
||||
const normalizedSelectedTagIds = normalizeTagIds(selectedTagIds);
|
||||
@@ -220,11 +221,46 @@
|
||||
lessonDownload: !!course.metadata?.lessonDownload,
|
||||
isPublished: !!course.isPublished,
|
||||
allowNewStudents: !!course.metadata?.allowNewStudent,
|
||||
isContentGroupingEnabled: course.metadata?.isContentGroupingEnabled ?? true
|
||||
isContentGroupingEnabled: course.metadata?.isContentGroupingEnabled ?? true,
|
||||
callout: normalizeCallout(course.callout)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeCalloutForSave(value: typeof $settings.callout) {
|
||||
if (!value) return null;
|
||||
|
||||
const title = value.title.trim();
|
||||
const description = value.description.trim();
|
||||
const buttonLabel = value.buttonLabel.trim();
|
||||
const buttonUrl = value.buttonUrl.trim();
|
||||
|
||||
if (!title && !description && !buttonLabel && !buttonUrl) return null;
|
||||
|
||||
return { title, description, buttonLabel, buttonUrl };
|
||||
}
|
||||
|
||||
function normalizeCallout(value: unknown): typeof $settings.callout {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof candidate.title !== 'string' ||
|
||||
typeof candidate.description !== 'string' ||
|
||||
typeof candidate.buttonLabel !== 'string' ||
|
||||
typeof candidate.buttonUrl !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
title: candidate.title,
|
||||
description: candidate.description,
|
||||
buttonLabel: candidate.buttonLabel,
|
||||
buttonUrl: candidate.buttonUrl
|
||||
};
|
||||
}
|
||||
|
||||
// Initialize course from page data
|
||||
$effect(() => {
|
||||
const courseData = $page.data?.course;
|
||||
@@ -240,6 +276,15 @@
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if ($settings.type === 'PUBLIC' && $settings.callout === null) {
|
||||
settings.update((prev) => ({
|
||||
...prev,
|
||||
callout: { title: '', description: '', buttonLabel: '', buttonUrl: '' }
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const courseId = courseApi.course?.id;
|
||||
|
||||
@@ -525,21 +570,97 @@
|
||||
}}
|
||||
>
|
||||
<div class="mb-3 flex items-center space-x-2">
|
||||
<RadioGroup.Item value={'LIVE_CLASS' as TCourseType} id="live-class" />
|
||||
<Label for="live-class">{$t('course.navItem.settings.live_class')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value={'SELF_PACED' as TCourseType} id="self-paced" />
|
||||
<Label for="self-paced">{$t('course.navItem.settings.self_paced')}</Label>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center space-x-2">
|
||||
<div class="mb-3 flex items-center space-x-2">
|
||||
<RadioGroup.Item value={'LIVE_CLASS' as TCourseType} id="live-class" />
|
||||
<Label for="live-class">{$t('course.navItem.settings.live_class')}</Label>
|
||||
</div>
|
||||
<div class="mb-3 flex items-center space-x-2">
|
||||
<RadioGroup.Item value={'COMPLIANCE' as TCourseType} id="compliance-course" />
|
||||
<Label for="compliance-course">{$t('course.navItem.settings.compliance')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value={'PUBLIC' as TCourseType} id="public-course" />
|
||||
<Label for="public-course">{$t('course.navItem.settings.public')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</Field.Field>
|
||||
|
||||
{#if courseApi.errors.type}
|
||||
<div
|
||||
class="ui:mt-2 ui:rounded-md ui:border ui:border-destructive/30 ui:bg-destructive/5 ui:p-3 ui:text-sm ui:text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
<div class="ui:font-medium">{$t('course.navItem.settings.convert_to_public_blocked')}</div>
|
||||
<p class="ui:mt-1 ui:text-destructive/90">{courseApi.errors.type}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</Field.Set>
|
||||
|
||||
{#if $settings.type === 'PUBLIC' && $settings.callout}
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('course.navItem.settings.callout.legend')}</Field.Legend>
|
||||
<Field.Description>{$t('course.navItem.settings.callout.description')}</Field.Description>
|
||||
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('course.navItem.settings.callout.title_label')}</Field.Label>
|
||||
<InputField
|
||||
bind:value={$settings.callout.title}
|
||||
onInputChange={() => (hasUnsavedChanges = true)}
|
||||
placeholder={$t('course.navItem.settings.callout.title_placeholder')}
|
||||
/>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('course.navItem.settings.callout.description_label')}</Field.Label>
|
||||
<TextareaField
|
||||
bind:value={$settings.callout.description}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
rows={3}
|
||||
placeholder={$t('course.navItem.settings.callout.description_placeholder')}
|
||||
/>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('course.navItem.settings.callout.button_label')}</Field.Label>
|
||||
<InputField
|
||||
bind:value={$settings.callout.buttonLabel}
|
||||
onInputChange={() => (hasUnsavedChanges = true)}
|
||||
placeholder={$t('course.navItem.settings.callout.button_label_placeholder')}
|
||||
/>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('course.navItem.settings.callout.button_url_label')}</Field.Label>
|
||||
<InputField
|
||||
bind:value={$settings.callout.buttonUrl}
|
||||
onInputChange={() => (hasUnsavedChanges = true)}
|
||||
placeholder={$t('course.navItem.settings.callout.button_url_placeholder')}
|
||||
type="url"
|
||||
/>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
|
||||
<Field.Field>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
settings.update((prev) => ({ ...prev, callout: null }));
|
||||
hasUnsavedChanges = true;
|
||||
}}
|
||||
>
|
||||
{$t('course.navItem.settings.callout.clear')}
|
||||
</Button>
|
||||
</Field.Field>
|
||||
</Field.Set>
|
||||
{/if}
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
|
||||
@@ -33,6 +33,8 @@ export const defaultCourse: Course = {
|
||||
bannerImage: null,
|
||||
attendance: [],
|
||||
group: null,
|
||||
compliance: null,
|
||||
callout: null,
|
||||
content: {
|
||||
grouped: false,
|
||||
sections: [],
|
||||
|
||||
@@ -46,6 +46,7 @@ type QuestionOption = ApiOption & {
|
||||
|
||||
// Extended question type with client-side fields
|
||||
export type Question = Omit<ApiQuestion, 'options'> & {
|
||||
exerciseSectionId?: string | null;
|
||||
isDirty?: boolean;
|
||||
deletedAt?: string;
|
||||
options: QuestionOption[];
|
||||
|
||||
@@ -8,6 +8,18 @@ interface OpenCoursePreviewOptions {
|
||||
currentOrgDomain?: string;
|
||||
}
|
||||
|
||||
/** Absolute URL for the org-site public course page (`/course/{slug}`). Client-only. */
|
||||
export function getPublicCoursePageUrl(courseSlug: string, currentOrgDomain = ''): string {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const trimmedDomain = currentOrgDomain?.trim();
|
||||
const origin = trimmedDomain || window.location.origin;
|
||||
|
||||
return new URL(resolve(`/course/${courseSlug}`, {}), origin).toString();
|
||||
}
|
||||
|
||||
export function openCoursePreview({ courseId, courseSlug, currentOrgDomain = '' }: OpenCoursePreviewOptions) {
|
||||
if (!courseSlug) {
|
||||
snackbar.info('course.header.preview_missing_slug');
|
||||
@@ -19,9 +31,32 @@ export function openCoursePreview({ courseId, courseSlug, currentOrgDomain = ''
|
||||
return false;
|
||||
}
|
||||
|
||||
const origin = currentOrgDomain || window.location.origin;
|
||||
const link = new URL(resolve(`/course/${courseSlug}`, {}), origin).toString();
|
||||
const link = getPublicCoursePageUrl(courseSlug, currentOrgDomain);
|
||||
|
||||
if (!link) {
|
||||
return false;
|
||||
}
|
||||
|
||||
window.open(link, '_blank', 'noopener,noreferrer');
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function copyPublicCoursePageUrl(courseSlug: string, currentOrgDomain = '') {
|
||||
const url = getPublicCoursePageUrl(courseSlug, currentOrgDomain);
|
||||
|
||||
if (!url) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
snackbar.success('snackbar.public_course.url_copied');
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('copyPublicCoursePageUrl error:', error);
|
||||
snackbar.error('snackbar.public_course.url_copy_failed');
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,74 @@
|
||||
import type { Exercise } from '$features/course/utils/types';
|
||||
import type { Question } from '$features/course/types';
|
||||
import { exerciseApi } from '$features/course/api';
|
||||
import type { ExerciseSectionAfterBehavior, ExerciseSectionColorTheme } from '@cio/question-types';
|
||||
import {
|
||||
clearQuestionnaireValidation,
|
||||
questionnaire,
|
||||
questionnaireMetaData
|
||||
} from '$features/course/components/exercise/store';
|
||||
import { getQuestionTypeId, getQuestionTypeOptionById } from '$features/course/components/exercise/question-type-utils';
|
||||
|
||||
import type { Exercise } from '$features/course/utils/types';
|
||||
import type { ExerciseSectionState } from '$features/course/components/exercise/store';
|
||||
import type { Question } from '$features/course/types';
|
||||
import { UNTITLED_EXERCISE_SECTION_TITLE } from './exercise-section-utils';
|
||||
import { exerciseApi } from '$features/course/api';
|
||||
import { normalizeQuestionOrder } from '$features/course/components/exercise/order-utils';
|
||||
|
||||
export function hydrateExercisePageData(exercise: Exercise, exerciseId: string) {
|
||||
let questions: Question[] = [];
|
||||
|
||||
if (exercise.questions && Array.isArray(exercise.questions)) {
|
||||
const mappedQuestions = exercise.questions.map((question) => {
|
||||
const sections: ExerciseSectionState[] = Array.isArray(exercise.sections)
|
||||
? exercise.sections
|
||||
.map((section) => ({
|
||||
id: section.id,
|
||||
title: section.title,
|
||||
description: section.description ?? null,
|
||||
order: section.order,
|
||||
colorTheme: section.colorTheme as ExerciseSectionColorTheme,
|
||||
afterBehavior: section.afterBehavior as unknown as ExerciseSectionAfterBehavior
|
||||
}))
|
||||
.sort((left, right) => left.order - right.order)
|
||||
: [];
|
||||
|
||||
const exerciseQuestions = [...(Array.isArray(exercise.questions) ? exercise.questions : [])];
|
||||
|
||||
if (exerciseQuestions.length > 0) {
|
||||
const mappedQuestions: Question[] = exerciseQuestions.map((question) => {
|
||||
const questionType = getQuestionTypeOptionById(getQuestionTypeId(question));
|
||||
|
||||
return {
|
||||
...question,
|
||||
exerciseSectionId: question.exerciseSectionId ?? null,
|
||||
questionTypeId: questionType.id,
|
||||
questionType
|
||||
};
|
||||
});
|
||||
|
||||
if (sections.length > 0) {
|
||||
const unsectionedQuestions = mappedQuestions.filter(
|
||||
(question) => !question.deletedAt && !question.exerciseSectionId
|
||||
);
|
||||
|
||||
if (unsectionedQuestions.length > 0) {
|
||||
const untitledSectionId = crypto.randomUUID();
|
||||
const nextSectionOrder =
|
||||
sections.reduce((highestOrder, section) => Math.max(highestOrder, section.order), -1) + 1;
|
||||
sections.push({
|
||||
id: untitledSectionId,
|
||||
title: UNTITLED_EXERCISE_SECTION_TITLE,
|
||||
description: null,
|
||||
order: nextSectionOrder,
|
||||
colorTheme: 'blue',
|
||||
afterBehavior: { action: 'continue' },
|
||||
isDirty: true
|
||||
});
|
||||
|
||||
for (const question of unsectionedQuestions) {
|
||||
question.exerciseSectionId = untitledSectionId;
|
||||
question.isDirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
questions = normalizeQuestionOrder(mappedQuestions);
|
||||
}
|
||||
|
||||
@@ -36,8 +82,11 @@ export function hydrateExercisePageData(exercise: Exercise, exerciseId: string)
|
||||
isDescriptionDirty: false,
|
||||
isDueByDirty: false,
|
||||
questions,
|
||||
sections,
|
||||
sectionDisplayMode: exercise.sectionDisplayMode ?? 'one_question',
|
||||
totalSubmissions: 0,
|
||||
allowMultipleAttempts: !!exercise.allowMultipleAttempts
|
||||
allowMultipleAttempts: !!exercise.allowMultipleAttempts,
|
||||
slug: exercise.slug ?? ''
|
||||
});
|
||||
|
||||
questionnaireMetaData.update((metadata) => ({ ...metadata, exerciseId }));
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export const UNTITLED_EXERCISE_SECTION_TITLE = 'Untitled section';
|
||||
|
||||
export function getExerciseSectionDisplayTitle({
|
||||
title,
|
||||
sectionNumber,
|
||||
sectionLabel
|
||||
}: {
|
||||
title: string;
|
||||
sectionNumber: number;
|
||||
sectionLabel: string;
|
||||
}) {
|
||||
if (title.trim() === UNTITLED_EXERCISE_SECTION_TITLE) return `${sectionLabel} ${sectionNumber}`;
|
||||
|
||||
return title;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type {
|
||||
PublicCourseSidebarSection,
|
||||
PublicExerciseViewData,
|
||||
PublicLessonViewData
|
||||
} from '@cio/ui/custom/public-course';
|
||||
import type { ExerciseQuestionModel, ExerciseQuestionTypeKey } from '@cio/question-types';
|
||||
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
|
||||
|
||||
type GetPublicCourseSuccess = Extract<
|
||||
InferResponseType<(typeof classroomio)['org-site']['course'][':courseSlug']['$get']>,
|
||||
{ success: true }
|
||||
>;
|
||||
|
||||
type PublicCourseTree = GetPublicCourseSuccess['data'];
|
||||
|
||||
type GetPublicCourseItemSuccess = Extract<
|
||||
InferResponseType<(typeof classroomio)['org-site']['course'][':courseSlug']['item'][':itemSlug']['$get']>,
|
||||
{ success: true }
|
||||
>;
|
||||
|
||||
type PublicItem = GetPublicCourseItemSuccess['data'];
|
||||
|
||||
/**
|
||||
* Group the flat tree into sidebar sections. Items that aren't assigned to any
|
||||
* section are grouped under a synthetic "Lessons" section so the UI never has
|
||||
* orphaned rows.
|
||||
*/
|
||||
export function toPublicSidebarSections(tree: PublicCourseTree): PublicCourseSidebarSection[] {
|
||||
const sectionLookup = new Map<string, PublicCourseSidebarSection>();
|
||||
for (const section of tree.sections) {
|
||||
sectionLookup.set(section.id, {
|
||||
id: section.id,
|
||||
title: section.title,
|
||||
items: []
|
||||
});
|
||||
}
|
||||
|
||||
const orphanSection: PublicCourseSidebarSection = {
|
||||
id: '__unsectioned',
|
||||
title: 'Lessons',
|
||||
items: []
|
||||
};
|
||||
|
||||
for (const item of tree.items) {
|
||||
const targetSection =
|
||||
item.sectionId && sectionLookup.has(item.sectionId) ? sectionLookup.get(item.sectionId)! : orphanSection;
|
||||
|
||||
targetSection.items.push({
|
||||
kind: item.kind,
|
||||
id: item.id,
|
||||
slug: item.slug,
|
||||
title: item.title,
|
||||
isUnlocked: item.isUnlocked
|
||||
});
|
||||
}
|
||||
|
||||
const ordered = Array.from(sectionLookup.values()).filter((section) => section.items.length > 0);
|
||||
|
||||
if (orphanSection.items.length > 0) {
|
||||
ordered.push(orphanSection);
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
export function toPublicLessonView(lesson: Extract<PublicItem, { kind: 'lesson' }>): PublicLessonViewData {
|
||||
return {
|
||||
kind: 'lesson',
|
||||
title: lesson.title,
|
||||
sectionTitle: lesson.sectionTitle,
|
||||
body: lesson.body ?? '',
|
||||
video: lesson.video,
|
||||
isUnlocked: lesson.isUnlocked
|
||||
};
|
||||
}
|
||||
|
||||
type ApiQuestion = Extract<PublicItem, { kind: 'exercise' }>['questions']['unsectioned'][number];
|
||||
|
||||
/**
|
||||
* Convert API question shapes into `ExerciseQuestionModel`s so the public
|
||||
* exercise view can drive the same renderer pipeline as the authenticated
|
||||
* student view (`ExerciseQuestion.QuestionList`). Question types that aren't
|
||||
* auto-gradable are filtered out — the creator UI already blocks them on
|
||||
* PUBLIC courses, but we double-check here so the page never ships answers
|
||||
* for a question we can't grade in the browser.
|
||||
*/
|
||||
export function toPublicExerciseQuestions(questions: {
|
||||
unsectioned: ApiQuestion[];
|
||||
sections: Array<{ questions: ApiQuestion[] }>;
|
||||
}): ExerciseQuestionModel[] {
|
||||
const all = [...questions.unsectioned, ...questions.sections.flatMap((section) => section.questions)];
|
||||
|
||||
return all.map((question) => mapQuestion(question));
|
||||
}
|
||||
|
||||
function mapQuestion(question: ApiQuestion): ExerciseQuestionModel {
|
||||
return {
|
||||
id: question.id,
|
||||
title: question.title,
|
||||
questionType: question.questionTypeKey as ExerciseQuestionTypeKey,
|
||||
points: question.points,
|
||||
options: question.options.map((option) => ({
|
||||
id: option.id,
|
||||
label: option.label,
|
||||
isCorrect: option.isCorrect,
|
||||
settings: (option.settings ?? {}) as Record<string, unknown>
|
||||
})),
|
||||
settings: (question.settings ?? {}) as Record<string, unknown>
|
||||
};
|
||||
}
|
||||
|
||||
export function toPublicExerciseView(exercise: Extract<PublicItem, { kind: 'exercise' }>): PublicExerciseViewData {
|
||||
return {
|
||||
kind: 'exercise',
|
||||
title: exercise.title,
|
||||
description: exercise.description,
|
||||
sectionTitle: exercise.sectionTitle,
|
||||
isUnlocked: exercise.isUnlocked,
|
||||
questions: toPublicExerciseQuestions(exercise.questions)
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TCourseType } from '@cio/db/types';
|
||||
import type { TCourseCallout } from '@cio/utils/validation/course';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
type CourseSettings = {
|
||||
@@ -6,12 +7,13 @@ type CourseSettings = {
|
||||
courseTitle: string;
|
||||
courseDescription: string;
|
||||
grading: boolean;
|
||||
type: 'LIVE_CLASS' | 'SELF_PACED' | 'COMPLIANCE';
|
||||
type: TCourseType;
|
||||
allowNewStudents: boolean;
|
||||
tabs: { id: number; name: string }[];
|
||||
lessonDownload: boolean;
|
||||
isPublished: boolean;
|
||||
isContentGroupingEnabled: boolean;
|
||||
callout: TCourseCallout | null;
|
||||
};
|
||||
|
||||
export const settings = writable<CourseSettings>({
|
||||
@@ -19,7 +21,7 @@ export const settings = writable<CourseSettings>({
|
||||
courseTitle: '',
|
||||
courseDescription: '',
|
||||
grading: false,
|
||||
type: 'LIVE_CLASS' as TCourseType,
|
||||
type: 'SELF_PACED' as TCourseType,
|
||||
allowNewStudents: false,
|
||||
tabs: [
|
||||
{ id: 1, name: 'course.navItem.lessons.materials.tabs.note.title' },
|
||||
@@ -29,5 +31,6 @@ export const settings = writable<CourseSettings>({
|
||||
],
|
||||
lessonDownload: false,
|
||||
isPublished: false,
|
||||
isContentGroupingEnabled: true
|
||||
isContentGroupingEnabled: true,
|
||||
callout: null
|
||||
});
|
||||
|
||||
@@ -261,6 +261,21 @@ export type UpdateSubmissionGradesRequest =
|
||||
export type CreateCourseRequest = typeof classroomio.course.$post;
|
||||
export type GetCourseRequest = (typeof classroomio.course)[':courseId']['$get'];
|
||||
export type GetCourseBySlugRequest = (typeof classroomio.course)['slug'][':slug']['$get'];
|
||||
export type GetCourseBySlugSuccess = Extract<InferResponseType<GetCourseBySlugRequest>, { success: true }>;
|
||||
export type GetCourseBySlugData = GetCourseBySlugSuccess['data'];
|
||||
|
||||
/** Org snippet returned with course slug payload for org-site flows */
|
||||
export type CourseSlugPayloadOrg = {
|
||||
id: string;
|
||||
name: string;
|
||||
siteName: string | null;
|
||||
theme?: unknown;
|
||||
};
|
||||
|
||||
/** Slug course shape including optional org (RPC unions omit `org` on some branches). */
|
||||
export type CourseBySlugWithOrg = GetCourseBySlugData & {
|
||||
org?: CourseSlugPayloadOrg | null;
|
||||
};
|
||||
export type UpdateCourseRequest = (typeof classroomio.course)[':courseId']['$put'];
|
||||
export type DeleteCourseRequest = (typeof classroomio.course)[':courseId']['$delete'];
|
||||
export type UpdateCourseContentRequest = (typeof classroomio.course)[':courseId']['content']['$put'];
|
||||
@@ -444,3 +459,10 @@ export type VideoUploadPresignSuccess = Extract<InferResponseType<VideoUploadPre
|
||||
|
||||
export type VideoDownloadPresignRequest = (typeof classroomio.course)['presign']['video']['download']['$post'];
|
||||
export type VideoDownloadPresignSuccess = Extract<InferResponseType<VideoDownloadPresignRequest>, { success: true }>;
|
||||
|
||||
export type VideoRecordingUploadInitRequest =
|
||||
(typeof classroomio.course)[':courseId']['exercise'][':exerciseId']['question'][':questionId']['video-recording']['upload']['init']['$post'];
|
||||
export type VideoRecordingUploadCompleteRequest =
|
||||
(typeof classroomio.course)[':courseId']['exercise'][':exerciseId']['question'][':questionId']['video-recording']['upload']['complete']['$post'];
|
||||
export type VideoRecordingPlaybackRequest =
|
||||
(typeof classroomio.course)[':courseId']['exercise'][':exerciseId']['submission'][':submissionId']['question'][':questionId']['video-recording']['playback']['$get'];
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user