feat: HLS streaming, transcripts, agent course generation & landing-page course previews (#663)

New features:
- HLS video encoding/streaming (hls routes, hls-encoder) and a media
  transcripts pipeline (transcripts route + media-transcript queries)
- AI assistant chat vs. run model: agent-course-generation worker/queue,
  run-monitor pane, chat-changes card, content ask-ai bar
- Per-theme course-page previews across org landing-page themes (bold,
  classic, corporate, editorial, etc.) with a shared course token/component set
- View-as-student mode and login-link / link-invite invite flows

Refactor:
- Extract packages/core consolidating config (env, storage, dashboard-url),
  services and utils previously duplicated under apps/api/src/config
- Remove legacy agent service modules (chat-tools, chat-context,
  context-window, document, fetch-url, lesson-content)
- Relax Cross-Origin-Resource-Policy to allow cross-origin media embedding
- Add format script to tenant-router so turbo run format covers it
This commit is contained in:
Rotimi Best
2026-06-07 18:43:05 +01:00
committed by GitHub
parent a2e70779a0
commit d5b07d356e
496 changed files with 19050 additions and 8312 deletions
+13 -13
View File
@@ -12,14 +12,6 @@ POSTGRES_DB=classroomio
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
# Public URL where the API is reachable from browsers
# Local: http://localhost:3081 | Production: https://api.yourdomain.com
PUBLIC_SERVER_URL=http://localhost:3081
# Comma-separated origins allowed by CORS
# Local: http://localhost:3082 | Production: https://app.yourdomain.com
TRUSTED_ORIGINS=http://localhost:3082,http://localhost:5173
# Auth secret for session signing (use a long random string in production)
BETTER_AUTH_SECRET=replace-with-a-long-random-secret
@@ -42,9 +34,21 @@ PRIVATE_SERVER_URL=http://api:3081
# When set, invite emails and other links point here instead of app.classroomio.com
DASHBOARD_ORIGIN=http://localhost:3082
# Optional direct API origin. Dashboard browser calls are proxied through
# DASHBOARD_ORIGIN, so normal self-hosted auth does not need this to share a
# cookie domain with the dashboard.
# Local: http://localhost:3081 | Production fallback: https://api.yourdomain.com
PUBLIC_SERVER_URL=http://localhost:3081
# Optional comma-separated origins allowed when browsers call the API directly.
# Normal self-hosted dashboard auth/API traffic is same-origin through the dashboard proxy.
# DASHBOARD_ORIGIN is trusted automatically.
TRUSTED_ORIGINS=
# CSP: external domains allowed by Content-Security-Policy.
# These are runtime env vars — set them when running the container (no rebuild needed).
# Comma-separated. Include your CDN, API, video, and font domains.
# Comma-separated. Include your CDN, video, and font domains. You do not need
# to add the API here for normal dashboard traffic because API calls are proxied same-origin.
# ALLOWED_EXTERNAL_DOMAINS overrides all per-directive vars below.
ALLOWED_EXTERNAL_DOMAINS=
# Per-directive overrides (optional, comma-separated):
@@ -96,10 +100,6 @@ GOOGLE_CLIENT_SECRET=
# PUBLIC_GOOGLE_PICKER_CLIENT_ID=
# PUBLIC_GOOGLE_PICKER_API_KEY=
# Cookie domain for auth. Set to your root domain (e.g. .yourdomain.com) when API and dashboard use different subdomains.
# Without this, auth cookies may not be set and login will fail.
AUTH_COOKIE_DOMAIN=
# Dashboard domain routing
PRIVATE_APP_HOST=localhost
PRIVATE_APP_SUBDOMAINS=app
+2
View File
@@ -13,12 +13,14 @@ Use this file to keep a single prioritized view of all PRDs.
| 5 | 3 | `prd/course-cohorts` | todo |
| 6 | 7 | `prd/email-template-customization` | todo |
| 7 | - | `prd/selective-exercise-assignment` | todo |
| - | - | `prd/scorm-support` | todo |
| - | 4 | `prd/course-content-search` | todo |
| - | 9 | `prd/onboarding-ai-bootstrap` | todo |
| - | 10 | `prd/pricing-improvements` | todo |
| - | 12 | `prd/public-courses` | todo |
| - | - | `prd/customer-course-documentation` | todo |
| - | - | `prd/walkthrough-widget` | draft |
| - | - | `prd/copy-page-as-markdown` | todo |
| - | 15 | `prd/webhooks` | todo |
## DONE (Completed PRDs)
+30 -19
View File
@@ -8,35 +8,46 @@
</p>
</a>
[![Codacy Badge](https://api.codacy.com/project/badge/Grade/629e2bb8994345729513c4d69ccbe3d5)](https://app.codacy.com/gh/classroomio/classroomio?utm_source=github.com&utm_medium=referral&utm_content=classroomio/classroomio&utm_campaign=Badge_Grade)
## ✨ About ClassroomIO.com
<img alt="ClassroomIO Courses page" src="https://raw.githubusercontent.com/classroomio/classroomio/main/apps/website/static/classroomio-courses.png" />
<img alt="ClassroomIO Courses page" src="https://assets.cdn.clsrio.com/www/home/ai-course-builder.jpeg" />
Streamline training for everyone with ClassroomIO.com. Our all-in-one platform empowers bootcamps, educators, and businesses to manage training programs easily. With our platform, anyone can run multiple classes and cohorts all from one UI. The application is mobile-first, which means that students can access your lesson materials from any device.
ClassroomIO is an open source LMS for companies. Run compliance/employee training, customer education, and partner certification programs — all from one platform. Self-host it on your own infrastructure or use the cloud version.
### Mission: Provide Students with the Best Learning Experience
### Who It's For
At ClassroomIO, our mission is to provide students with the best possible learning experience. We believe in empowering educators with the tools they need to deliver high-quality education that is accessible, engaging, and effective.
| Use Case | What You Get |
|---|---|
| **Compliance Training** | Deadline tracking, renewals & retake intervals, grace periods, waivers, certificates with custom IDs |
| **Customer Education** | Branded academy portal, programs (cohorts with goals), AI lesson tutor, multilingual content |
| **Partner Training** | Partner workspaces, branded certificates, custom domains, embeddable widgets, multilingual support |
### Key Features
1. **📚 Advanced Course Management:** You can create unlimited courses, create lessons, invite students, add assignments, grade their assignments, and even generate certificates.
2. **👨‍👩‍👦 Multi-Teacher Management:** You can invite other teachers into your organization and assign them individual courses.
3. **🤖 AI Integration:** We've got OpenAI integration for quick course creation where you can generate course content, lesson outlines, and even generate assignments right from your lesson notes.
4. **💬 Forum:** Students can ask questions in your dedicated community and get answers from either you or other students.
5. **💻 Dedicated Student Dashboard:** Once you create an account, you get a dedicated dashboard where your students can access all their courses, assignments, and more.
6. **🔒 Fully open source:** You can self-host the entire stack on your servers.
**Course & Content**
- **Course management** — unlimited courses, lessons, exercises, grading, and certificates
- **Programs** — group courses into cohorts with goals, team management, and progress tracking
- **AI Course Builder** — generate outlines, lesson content, and assignments (Gemini, GPT-4o, Claude)
- **AI Lesson Tutor** — in-lesson AI assistant that helps learners as they study
### Roadmap Features
**Compliance & Certification**
- **Compliance tracking** — status, deadlines, grace periods, renewals, and waivers
- **Certificates** — issue branded certificates with custom IDs
1. **Forms:** Instead of using Google Forms to collect vital information from your students, you will be able to create forms directly within the dashboard.
2. **Course Templates:** You can clone a full course or share templates with other people.
3. **Analytics:** You can track data about your students across multiple courses.
4. **Run Courses on Messengers:** Students can just join a channel on slack/discord/telegram and a bot automatically sends daily lesson content to your students without you doing anything.
**Learner & Org Experience**
- **Multi-org & multi-teacher** — invite teachers, assign courses, manage multiple organizations
- **Student dashboard** — learners access all courses, assignments, and progress in one place
- **Multilingual** — deliver content in 10+ languages
Please reach out to me on [twitter](https://x.com/rotimi_best) if you have any feature request.
**Integrations & Developer Tools**
- **REST API + Webhooks** — enroll users, trigger automations, receive events (`certificate.issued`, `enrollment.completed`, and more)
- **MCP server** — `@classroomio/mcp` on npm for AI-native integrations
- **Embeddable widget** — embed your course catalog on any website
**Platform**
- **Fully open source** — self-host the entire stack on your own servers
For what's coming next, see the [public roadmap](https://classroomio.com/roadmap).
## Built With
@@ -121,7 +132,7 @@ The repository also contains shared packages under `packages/` (for example `pac
- Duplicate the `.env.example` file and rename it to `.env`
- Populate them with required values (at minimum):
- `apps/api/.env`: `DATABASE_URL`, `REDIS_URL`, `AUTH_BEARER_TOKEN`, `BETTER_AUTH_SECRET`
- `apps/dashboard/.env`: `PUBLIC_SERVER_URL`, `PRIVATE_SERVER_KEY`, `PUBLIC_IS_SELFHOSTED`
- `apps/dashboard/.env`: `PRIVATE_SERVER_URL`, `PRIVATE_SERVER_KEY`, `PUBLIC_IS_SELFHOSTED`, `DASHBOARD_ORIGIN`
- Optional for self-hosted Enterprise-only features (SSO, token-auth, no-tracking): set `LICENSE_KEY` in `apps/api/.env`
6. Start local infrastructure for API (Postgres + Redis) and seed the DB:
+8
View File
@@ -41,6 +41,14 @@ CLOUDFLARE_ACCOUNT_ID=""
CLOUDFLARE_BUCKET_DOMAIN=""
CLOUDFLARE_SECRET_ACCESS_KEY=""
# HMAC secret used to sign the `cio_hls` playback cookie. The same value must
# be set on the tenant-router Worker (`wrangler secret put HLS_SIGNING_SECRET`)
# so segments served from R2 at the edge verify against this signature.
# Without it the cookie endpoints return 503 and HLS playback won't start —
# uploads still work, so leave it unset for upload-only local development.
# Generate one with: node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
HLS_SIGNING_SECRET=""
# Sentry (server-side error tracking). Leave SENTRY_DSN unset to disable.
# Skipped automatically in dev (NODE_ENV !== 'production') and self-hosted (PUBLIC_IS_SELFHOSTED=true).
SENTRY_DSN=""
+80
View File
@@ -5,6 +5,86 @@
1. Certificate download for students.
2. Uploading videos to cloudflare (for storage) and Muse.ai (for transcription)
## Running locally with video upload + playback
Lesson videos run through an HLS pipeline on Cloud. The browser encodes a
360/720/1080 ABR ladder with Mediabunny, uploads segments to R2 via presigned
PUTs, and the player loads `/hls/{assetId}/master.m3u8` from the API.
How `/hls/*` resolves depends on the environment:
| | URL the browser hits | Who serves the bytes |
| ----------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| Cloud production | `https://<tenant-host>/hls/...` | Cloudflare Worker (`apps/tenant-router`) — intercepts the path and streams directly from an R2 binding. Render is never in the bytestream. |
| Local dev / self-hosted | `${PUBLIC_SERVER_URL}/hls/...` (direct to API) | Hono streaming route on the API — session-auth, then `getS3Client().send(GetObjectCommand)`. |
**Self-hosted (`PUBLIC_IS_SELFHOSTED=true`) skips HLS entirely** and falls
back to raw-MP4 upload — no R2-specific code path. Everything below is for
Cloud-style local development.
### Required env
`apps/api/.env`:
```bash
# Object storage — Cloudflare R2 OR an S3-compatible local backend (MinIO, etc.)
CLOUDFLARE_ACCOUNT_ID=…
CLOUDFLARE_ACCESS_KEY=…
CLOUDFLARE_SECRET_ACCESS_KEY=…
# (or OBJECT_STORAGE_ENDPOINT / OBJECT_STORAGE_ACCESS_KEY_ID / OBJECT_STORAGE_SECRET_ACCESS_KEY)
# Optional — only needed if you want extract-audio → Whisper transcripts
OPENAI_API_KEY=…
# Optional — Redis enables the BullMQ media job pipeline. Without it the
# asset row is still created, but no transcription/thumbnail jobs run.
REDIS_URL=redis://localhost:6379
# Only required when you want to exercise the production cookie-mint path
# (`POST /organization/assets/:assetId/hls/cookie`). Locally the Hono /hls/*
# route uses session auth, so most of the time you can leave this unset.
# Same value must also be set as a Cloudflare secret on tenant-router for prod.
HLS_SIGNING_SECRET=…
```
`apps/dashboard/.env`:
```bash
# Already required by the existing API client — used as the HLS base URL too.
PUBLIC_SERVER_URL=http://localhost:3000
```
### Run
```bash
pnpm --filter @cio/api dev
pnpm --filter @cio/dashboard dev
# (optional) pnpm --filter @cio/jobs dev # for transcription + thumbnail jobs
```
Drop a video into a lesson; the dashboard encodes locally, you'll see
segments arrive under `videos/{assetId}/hls/…` in R2 and an `assets` row with
`hls_manifest_key` populated. Playback flows directly browser → API → R2 —
**no wrangler dev needed locally.**
### Production setup (one-time)
For Cloud deployment, generate the HMAC signing key the Worker uses to verify
playback cookies and set it on both sides:
```bash
node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
```
```bash
# On Render (apps/api service env):
HLS_SIGNING_SECRET=<paste>
# On Cloudflare (apps/tenant-router):
pnpm --filter @cio/tenant-router exec wrangler secret put HLS_SIGNING_SECRET
# paste the same value when prompted
```
## Debugging Fly.io
View logs from fly.io in real time
+2 -2
View File
@@ -40,8 +40,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.388.0",
"@aws-sdk/s3-request-presigner": "^3.675.0",
"@bull-board/api": "^7.0.0",
"@bull-board/hono": "^7.0.0",
"@cio/core": "workspace:*",
"@cio/ai-assistant": "workspace:*",
"@cio/analytics": "workspace:*",
"@cio/certificates": "workspace:*",
@@ -50,6 +49,7 @@
"@cio/jobs": "workspace:*",
"@cio/question-types": "workspace:*",
"@cio/utils": "workspace:*",
"@getworkbench/hono": "0.2.1",
"@hono/node-server": "^1.19.7",
"@hono/standard-validator": "^0.1.5",
"@hono/zod-validator": "^0.7.6",
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { normalizeAgentLessonContent } from '../services/agent/lesson-content';
import { normalizeAgentLessonContent } from '@cio/core/services/agent/lesson-content';
describe('normalizeAgentLessonContent', () => {
it('removes a leading heading that duplicates the lesson title', () => {
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { computePurchasedTokenOverflow } from '@api/services/agent/usage';
import { computePurchasedTokenOverflow } from '@cio/core/services/agent/usage';
describe('computePurchasedTokenOverflow', () => {
it('returns 0 when usage stays within allowance', () => {
@@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest';
import { balanceCorrectAnswerPositions } from '@cio/core/services/agent/balance-answer-positions';
import { QUESTION_TYPE } from '@cio/utils/validation/constants';
type Option = { label: string; isCorrect: boolean };
type Question = { questionTypeId: number; question: string; options: Option[] };
function radio(question: string, labels: string[], correctIndex: number): Question {
return {
questionTypeId: QUESTION_TYPE.RADIO,
question,
options: labels.map((label, i) => ({ label, isCorrect: i === correctIndex }))
};
}
function correctPosition(question: Question): number {
return question.options.findIndex((option) => option.isCorrect);
}
describe('balanceCorrectAnswerPositions', () => {
it('spreads the correct RADIO position across the batch instead of clustering', () => {
// 12 questions, all authored with the answer at index 0 ("A is always right").
const questions = Array.from({ length: 12 }, (_, i) => radio(`Q${i}`, ['alpha', 'bravo', 'charlie', 'delta'], 0));
const balanced = balanceCorrectAnswerPositions(questions);
const counts = [0, 0, 0, 0];
balanced.forEach((question) => counts[correctPosition(question)]++);
// 12 questions / 4 options → exactly 3 per position when perfectly balanced.
expect(counts).toEqual([3, 3, 3, 3]);
});
it('keeps the same correct option, only its position changes', () => {
const questions = [radio('Q', ['alpha', 'bravo', 'charlie', 'delta'], 0)];
const [balanced] = balanceCorrectAnswerPositions(questions);
const correct = balanced.options.find((option) => option.isCorrect);
expect(correct?.label).toBe('alpha');
expect(balanced.options).toHaveLength(4);
expect(new Set(balanced.options.map((o) => o.label))).toEqual(new Set(['alpha', 'bravo', 'charlie', 'delta']));
// Exactly one correct option survives.
expect(balanced.options.filter((o) => o.isCorrect)).toHaveLength(1);
});
it('is deterministic for identical input', () => {
const build = () => Array.from({ length: 8 }, (_, i) => radio(`Q${i}`, ['a', 'b', 'c', 'd'], 0));
const first = balanceCorrectAnswerPositions(build()).map(correctPosition);
const second = balanceCorrectAnswerPositions(build()).map(correctPosition);
expect(first).toEqual(second);
});
it('does not shuffle options that reference other positions', () => {
const questions = [radio('Q', ['Paris', 'London', 'Both of the above', 'All of the above'], 3)];
const [balanced] = balanceCorrectAnswerPositions(questions);
expect(balanced.options.map((o) => o.label)).toEqual(['Paris', 'London', 'Both of the above', 'All of the above']);
});
it('leaves naturally-ordered numeric ranges untouched', () => {
const questions = [radio('Range?', ['0-10', '11-20', '21-30', '31-40'], 1)];
const [balanced] = balanceCorrectAnswerPositions(questions);
expect(balanced.options.map((o) => o.label)).toEqual(['0-10', '11-20', '21-30', '31-40']);
});
it('balances each option-count group independently', () => {
const threeOption = Array.from({ length: 6 }, (_, i) => radio(`T${i}`, ['x', 'y', 'z'], 0));
const fourOption = Array.from({ length: 8 }, (_, i) => radio(`F${i}`, ['a', 'b', 'c', 'd'], 0));
const balanced = balanceCorrectAnswerPositions([...threeOption, ...fourOption]);
const threeCounts = [0, 0, 0];
balanced.slice(0, 6).forEach((q) => threeCounts[correctPosition(q)]++);
expect(threeCounts).toEqual([2, 2, 2]);
const fourCounts = [0, 0, 0, 0];
balanced.slice(6).forEach((q) => fourCounts[correctPosition(q)]++);
expect(fourCounts).toEqual([2, 2, 2, 2]);
});
it('shuffles CHECKBOX options without dropping correct answers', () => {
// Correct answers authored at the top; expect them not all clustered after shuffle.
const questions: Question[] = Array.from({ length: 10 }, (_, i) => ({
questionTypeId: QUESTION_TYPE.CHECKBOX,
question: `C${i}`,
options: [
{ label: 'one', isCorrect: true },
{ label: 'two', isCorrect: true },
{ label: 'three', isCorrect: false },
{ label: 'four', isCorrect: false }
]
}));
const balanced = balanceCorrectAnswerPositions(questions);
balanced.forEach((question) => {
expect(question.options.filter((o) => o.isCorrect)).toHaveLength(2);
expect(new Set(question.options.map((o) => o.label))).toEqual(new Set(['one', 'two', 'three', 'four']));
});
// Index 0 should not be correct in every single question once shuffled.
const indexZeroAlwaysCorrect = balanced.every((q) => q.options[0].isCorrect);
expect(indexZeroAlwaysCorrect).toBe(false);
});
it('leaves TRUE_FALSE and malformed questions untouched', () => {
const trueFalse: Question = {
questionTypeId: QUESTION_TYPE.TRUE_FALSE,
question: 'TF',
options: [
{ label: 'True', isCorrect: true },
{ label: 'False', isCorrect: false }
]
};
const noCorrect = radio('Q', ['a', 'b', 'c'], -1);
const [balancedTf, balancedNone] = balanceCorrectAnswerPositions([trueFalse, noCorrect]);
expect(balancedTf.options.map((o) => o.label)).toEqual(['True', 'False']);
expect(balancedNone.options.map((o) => o.label)).toEqual(['a', 'b', 'c']);
});
});
@@ -4,13 +4,13 @@ import { getCourseById } from '@cio/db/queries/course';
import { getCourseContentItems, type CourseContentItemRow } from '@cio/db/queries/course/content';
import { getCourseOrganizationId } from '@cio/db/queries/tag';
import { getOrganizationById } from '@cio/db/queries/organization';
import { updateCourse } from '../services/course/course';
import { ensureCourseSlug, generateUniqueCourseSlug } from '../services/course/landing-page';
import { updateCourse } from '@cio/core/services/course/course';
import { ensureCourseSlug, generateUniqueCourseSlug } from '@cio/core/services/course/landing-page';
import {
evaluateCourseGoLiveReadiness,
getCourseGoLiveReadiness,
publishCourseWhenReady
} from '../services/course/go-live-readiness';
} from '@cio/core/services/course/go-live-readiness';
vi.mock('@cio/db/queries/course', () => ({
getCourseById: vi.fn()
@@ -28,16 +28,19 @@ vi.mock('@cio/db/queries/organization', () => ({
getOrganizationById: vi.fn()
}));
vi.mock('../services/course/course', () => ({
vi.mock('@cio/core/services/course/course', () => ({
updateCourse: vi.fn()
}));
vi.mock('../services/course/landing-page', () => ({
buildCourseBaseUrl: vi.fn(() => 'https://school.example.com'),
vi.mock('@cio/core/services/course/landing-page', () => ({
ensureCourseSlug: vi.fn(),
generateUniqueCourseSlug: vi.fn()
}));
vi.mock('@cio/core/config/dashboard-url', () => ({
getDashboardBaseUrl: vi.fn(() => 'https://school.example.com')
}));
const mockedGetCourseById = vi.mocked(getCourseById);
const mockedGetCourseContentItems = vi.mocked(getCourseContentItems);
const mockedGetCourseOrganizationId = vi.mocked(getCourseOrganizationId);
@@ -3,7 +3,7 @@ import {
buildUpdatedQuestions,
type CurrentQuestionForPatch,
type QuestionPatch
} from '@api/services/agent/question-update';
} from '@cio/core/services/agent/question-update';
import { QUESTION_TYPE } from '@cio/utils/validation/constants';
const EXERCISE_ID = 'ex-1';
+11 -1
View File
@@ -20,8 +20,10 @@ import { jobsRouter } from '@api/routes/jobs';
import { licenseRouter } from '@api/routes/license';
import { logger } from 'hono/logger';
// ROUTES
import { hlsRouter } from '@api/routes/hls';
import { mailRouter } from '@api/routes/mail';
import { mediaRouter } from '@api/routes/media';
import { transcriptsRouter } from '@api/routes/transcripts';
import { mountQueueDashboard } from '@api/routes/admin/queues';
import { onboardingRouter } from '@api/routes/onboarding';
import { organizationRouter } from '@api/routes/organization';
@@ -43,7 +45,13 @@ export const app = new Hono()
// Middleware
.use('*', logger())
.use('*', prettyJSON())
.use('*', secureHeaders())
// `Cross-Origin-Resource-Policy: same-origin` (Hono's default) blocks the
// dashboard from embedding api-served media (e.g. HLS segments via
// `<video>`) when the dashboard and api live on different origins in
// local dev / self-hosted. We're an API server — every response is
// designed for cross-origin consumption, so the right value is
// `cross-origin`. CORS still gates which origins can read the bytes.
.use('*', secureHeaders({ crossOriginResourcePolicy: 'cross-origin' }))
.use('*', async (c, next) => {
if (isPublicCorsPath(c.req.path)) return next();
@@ -193,6 +201,8 @@ export const app = new Hono()
.route('/account', accountRouter)
.route('/course', courseRouter)
.route('/domain', domainRouter)
.route('/hls', hlsRouter)
.route('/transcripts', transcriptsRouter)
.route('/mail', mailRouter)
.route('/media', mediaRouter)
.route('/jobs', jobsRouter)
-20
View File
@@ -1,20 +0,0 @@
import { env } from './env';
/**
* Returns the base URL for the dashboard app, used for invite links, email links, etc.
* When DASHBOARD_ORIGIN is set (e.g. self-hosted), it takes precedence.
* Otherwise: dev → localhost:5173, prod → app.classroomio.com (or subdomain for cloud).
*/
export function getDashboardBaseUrl(orgSiteName?: string): string {
if (env.DASHBOARD_ORIGIN) {
return env.DASHBOARD_ORIGIN.replace(/\/$/, '');
}
if (env.NODE_ENV === 'development') {
return 'http://localhost:5173';
}
const subdomain = orgSiteName || 'app';
return `https://${subdomain}.classroomio.com`;
}
+7 -2
View File
@@ -1,13 +1,18 @@
import { env } from '@api/config/env';
import { env } from '@cio/core/config/env';
export * from './rate-limiter';
export const API_PORT = env.PORT ? parseInt(env.PORT) : 3002;
export const API_SERVER_URL = env.PUBLIC_SERVER_URL || `http://localhost:${API_PORT}`;
export const TRUSTED_ORIGINS = env.TRUSTED_ORIGINS
const configuredTrustedOrigins = env.TRUSTED_ORIGINS
? env.TRUSTED_ORIGINS.split(',')
: ['http://localhost:5173', 'http://localhost:5180', 'https://*.classroomio.com', 'https://*.myclassroomio.com'];
export const TRUSTED_ORIGINS = [
...configuredTrustedOrigins.map((origin) => origin.trim()).filter(Boolean),
...(env.DASHBOARD_ORIGIN ? [env.DASHBOARD_ORIGIN.trim().replace(/\/$/, '')] : [])
];
const {
CLOUDFLARE_BUCKET_DOMAIN,
CLOUDFLARE_ACCESS_KEY,
+2 -2
View File
@@ -4,8 +4,8 @@ import './instrument';
import { API_PORT } from '@api/constants';
import { app } from '@api/app';
import { configureOpenAPI } from '@api/utils/openapi';
import { connectRedis } from '@api/utils/redis/redis';
import { env } from '@api/config/env';
import { connectRedis } from '@cio/core/utils/redis/redis';
import { env } from '@cio/core/config/env';
import { preloadVerifiedCustomDomainOriginsRegistry } from '@api/utils/origins';
import { serve } from '@hono/node-server';
import { showRoutes } from 'hono/dev';
+1 -1
View File
@@ -1,6 +1,6 @@
import { Context, Next } from 'hono';
import { env } from '@api/config/env';
import { env } from '@cio/core/config/env';
/**
* Middleware to authenticate requests using API key
+1 -1
View File
@@ -2,7 +2,7 @@ import { Context, Next } from 'hono';
import { ErrorCodes } from '@api/utils/errors';
import { isUserCourseMemberOrOrgAdmin } from '@cio/db/queries/group';
import { ensureProgramCourseAccess } from '@api/services/course/course';
import { ensureProgramCourseAccess } from '@cio/core/services/course/course';
/**
* Middleware to check if the authenticated user is a member of a course's group
+1 -1
View File
@@ -1,7 +1,7 @@
import type { Context, Next } from 'hono';
import type { LicenseFeatureId } from '@cio/utils/license';
import { env } from '@api/config/env';
import { env } from '@cio/core/config/env';
import { isFeatureLicensed } from '@api/services/license';
/**
@@ -1,7 +1,7 @@
import { Context, Next } from 'hono';
import { ErrorCodes } from '@api/utils/errors';
import { env } from '@api/config/env';
import { env } from '@cio/core/config/env';
import { isUserOrgAdmin } from '@cio/db/queries/organization';
export const orgAdminOrApiKeyMiddleware = async (c: Context, next: Next) => {
+2 -2
View File
@@ -8,8 +8,8 @@ import {
import type { Context, MiddlewareHandler } from 'hono';
import { RedisRateLimiter } from '@api/utils/redis/limiter';
import { userKeyGenerator } from '../utils/redis/key-generators';
import { env } from '@api/config/env';
import { logRedisUnavailableOnce, redis } from '@api/utils/redis/redis';
import { env } from '@cio/core/config/env';
import { logRedisUnavailableOnce, redis } from '@cio/core/utils/redis/redis';
/** Dashboard SSR and other internal callers authenticate with PRIVATE_SERVER_KEY. */
export function isTrustedServerApiKeyRequest(c: Context): boolean {
+1 -1
View File
@@ -6,7 +6,7 @@ import {
hasActiveOrganizationInviteForEmail
} from '@cio/db/queries/organization';
import { env } from '@api/config/env';
import { env } from '@cio/core/config/env';
type OrgSettings = {
signup?: {
+20 -1
View File
@@ -1,4 +1,4 @@
import { getAccountData, updateUser } from '@api/services/account';
import { createViewAsStudentToken, getAccountData, updateUser } from '@api/services/account';
import { Hono } from '@api/utils/hono';
import { ZUpdateProfile } from '@cio/utils/validation/account';
@@ -51,6 +51,25 @@ export const accountRouter = new Hono()
return handleError(c, error, 'Failed to update profile');
}
})
.post('/view-as-student-token', authMiddleware, async (c) => {
const user = c.get('user')!;
try {
// Self sign-in only — mints a login-link token for the current user so they
// can be auto-authenticated on the org domain to preview the student experience.
const token = await createViewAsStudentToken({ id: user.id, email: user.email });
return c.json(
{
success: true,
data: { token }
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to create view-as-student token');
}
})
.get('/profile', authMiddleware, async (c) => {
const user = c.get('user')!;
+14 -18
View File
@@ -1,15 +1,12 @@
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { HonoAdapter } from '@bull-board/hono';
import { serveStatic } from '@hono/node-server/serve-static';
import { workbench } from '@getworkbench/hono';
import type { Context, Hono, Next } from 'hono';
import { QUEUE_NAMES, getQueue, isRedisConfigured } from '@cio/jobs';
import { env } from '@api/config/env';
import { getQueue, isRedisConfigured, listQueueNames } from '@cio/jobs';
import { env } from '@cio/core/config/env';
/**
* Mounts the bull-board UI at `/admin/queues` for inspecting BullMQ state
* (waiting/active/completed/failed jobs, payloads, retries, etc.).
* Mounts the Workbench UI at `/admin/queues` for inspecting BullMQ state
* (jobs, flows, metrics, schedulers, retries, etc.).
*
* Access control:
* - Development: open (matches existing dev ergonomics).
@@ -29,25 +26,24 @@ export function mountQueueDashboard(app: Hono<any, any, any>): void {
const isProduction = env.NODE_ENV === 'production';
if (isProduction && !canEnforceProdAuth()) {
console.info(
'BullMQ dashboard (/admin/queues) not mounted in production. Set QUEUE_DASHBOARD_ADMIN_EMAILS or QUEUE_DASHBOARD_TOKEN to enable it.'
'Queue dashboard (/admin/queues) not mounted in production. Set QUEUE_DASHBOARD_ADMIN_EMAILS or QUEUE_DASHBOARD_TOKEN to enable it.'
);
return;
}
const serverAdapter = new HonoAdapter(serveStatic);
serverAdapter.setBasePath('/admin/queues');
createBullBoard({
queues: Object.values(QUEUE_NAMES).map((name) => new BullMQAdapter(getQueue(name))),
serverAdapter
});
if (isProduction) {
app.use('/admin/queues/*', queueDashboardAuth);
app.use('/admin/queues', queueDashboardAuth);
}
app.route('/admin/queues', serverAdapter.registerPlugin());
app.route(
'/admin/queues',
workbench({
basePath: '/admin/queues',
queues: listQueueNames().map((name) => getQueue(name)),
title: 'ClassroomIO Jobs'
})
);
}
function canEnforceProdAuth(): boolean {
+18 -14
View File
@@ -1,3 +1,4 @@
import type { ContentfulStatusCode } from 'hono/utils/http-status';
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { orgMemberMiddleware } from '@api/middlewares/org-member';
@@ -28,7 +29,7 @@ import {
getTokenBalance,
isOrgOnPaidPlan,
recordTokenUsage
} from '@api/services/agent/usage';
} from '@cio/core/services/agent/usage';
import {
enforceStudentTutorPolicy,
getStudentTutorStatus,
@@ -38,7 +39,7 @@ import {
incrementStudentTutorCount
} from '@api/services/agent/tutor-usage';
import { buildStudentAgentTools } from '@api/services/agent/student-tools';
import { parseAndStoreDocument } from '@api/services/agent/document';
import { parseAndStoreDocument } from '@cio/core/services/agent/document';
import { recordCreditPurchase } from '@api/services/agent/credit-purchase';
import { generateCourseMeta } from '@api/services/agent/title-generation';
import { generateFieldText } from '@api/services/agent/text-generation';
@@ -55,15 +56,15 @@ import {
import { createModel, getProviderConfigForProvider, pickAnyConfiguredProvider } from '@cio/ai-assistant/providers';
import { AGENT_MODELS, DEFAULT_PICKER_MODEL_ID } from '@cio/utils/agent-models';
import { buildSystemPrompt, buildContextMessage } from '@cio/ai-assistant/prompt';
import { trackAgentEvent, AgentEvent } from '@api/utils/tinybird';
import { redis } from '@api/utils/redis/redis';
import { trackAgentEvent, AgentEvent } from '@cio/core/utils/tinybird';
import { redis } from '@cio/core/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 { sanitizeDanglingToolCalls } from '@api/services/agent/sanitize-tool-calls';
import { listCourseSections } from '@cio/core/services/course/section';
import { getLesson } from '@cio/core/services/lesson/lesson';
import { getExercise } from '@cio/core/services/exercise/exercise';
import { sanitizeDanglingToolCalls } from '@cio/core/services/agent/sanitize-tool-calls';
import {
collectDocumentIds,
getActiveCourseTemplateId,
@@ -71,10 +72,10 @@ import {
loadDocumentsText,
verifyExerciseBelongsToCourse,
verifyLessonBelongsToCourse
} from '@api/services/agent/chat-context';
import { buildAgentTools } from '@api/services/agent/chat-tools';
import { buildModelContextMessages } from '@api/services/agent/model-context';
import { summarizeConversation } from '@api/services/agent/summarize';
} from '@cio/core/services/agent/chat-context';
import { buildAgentTools, filterToolsForChatMode } from '@cio/core/services/agent/chat-tools';
import { buildModelContextMessages } from '@cio/core/services/agent/model-context';
import { summarizeConversation } from '@cio/core/services/agent/summarize';
import { agentHistoryRouter } from './history';
import { agentRunsRouter } from './runs';
@@ -530,7 +531,7 @@ const agentCoreRouter = new Hono()
const agentTools =
role === AgentRole.STUDENT
? buildStudentAgentTools(orgId, user.id, courseId, studentPolicy!.settings)
: buildAgentTools(orgId, user.id, courseId, messages, { isOrgOnPaidPlan: isOrgPaid });
: filterToolsForChatMode(buildAgentTools(orgId, user.id, courseId, messages, { isOrgOnPaidPlan: isOrgPaid }));
const contextManaged = await buildModelContextMessages({
conversationId,
@@ -677,7 +678,10 @@ const agentCoreRouter = new Hono()
});
if (error instanceof AppError) {
return c.json({ success: false, error: error.message, code: error.code }, error.statusCode);
return c.json(
{ success: false, error: error.message, code: error.code },
error.statusCode as ContentfulStatusCode
);
}
console.error('Agent chat error:', error);
+1 -1
View File
@@ -1,5 +1,5 @@
import { ZCourseContentDelete, ZCourseContentReorder, ZCourseContentUpdate } from '@cio/utils/validation/course';
import { deleteCourseContent, reorderCourseContent, updateCourseContent } from '@api/services/course/content';
import { deleteCourseContent, reorderCourseContent, updateCourseContent } from '@cio/core/services/course/content';
import { Hono } from '@api/utils/hono';
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
+2 -2
View File
@@ -25,7 +25,7 @@ import {
getCourseAnalytics,
getCourseProgress,
updateCourse
} from '@api/services/course/course';
} from '@cio/core/services/course/course';
import { assertCertificateDownloadAllowed, evaluateCourseCertification } from '@api/services/course/completion';
import { getCourseTags, replaceCourseTags } from '@api/services/tag';
@@ -62,7 +62,7 @@ import { presignRouter } from '@api/routes/course/presign';
import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage';
import { sectionRouter } from '@api/routes/course/section';
import { submissionRouter } from '@api/routes/course/submission';
import { updateCourseLandingPageService } from '@api/services/course/landing-page';
import { updateCourseLandingPageService } from '@cio/core/services/course/landing-page';
import { zValidator } from '@hono/zod-validator';
function slugifyForFilename(value: string): string {
+1 -1
View File
@@ -20,7 +20,7 @@ import {
getExercise,
listExercises,
updateExerciseService
} from '@api/services/exercise/exercise';
} from '@cio/core/services/exercise/exercise';
import { fetchAllTemplatesMetadata, fetchTemplateById, fetchTemplatesByTag } from '@api/services/exercise/template';
import { getGroupMemberIdByCourseAndProfile, isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group';
import {
@@ -9,7 +9,7 @@ import {
listLessonLanguages,
updateLessonLanguageService,
upsertLessonLanguageService
} from '@api/services/lesson-language';
} from '@cio/core/services/lesson-language';
import { Hono } from '@api/utils/hono';
import type { TLocale } from '@db/types';
+1 -1
View File
@@ -9,7 +9,7 @@ import { addMembers, deleteMember, listCourseMembers, updateMember } from '@api/
import { Hono } from '@api/utils/hono';
import { ZCourseUserAnalyticsParam } from '@cio/utils/validation/course';
import { courseTeamMemberMiddleware } from '@api/middlewares/course-team-member';
import { getUserCourseAnalytics } from '@api/services/course/course';
import { getUserCourseAnalytics } from '@cio/core/services/course/course';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
+2 -2
View File
@@ -9,11 +9,11 @@ import {
generateDocumentUploadPresignedUrl,
generateVideoDownloadPresignedUrls,
generateVideoUploadPresignedUrl
} from '@api/utils/s3';
} from '@cio/core/utils/s3';
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { generateFileKey } from '@api/utils/upload';
import { generateFileKey } from '@cio/core/utils/upload';
// Response schemas for OpenAPI documentation
const PresignUploadResponse = {
+1 -1
View File
@@ -8,7 +8,7 @@ import {
promoteUngroupedSection,
reorderCourseSections,
updateCourseSectionService
} from '@api/services/course/section';
} from '@cio/core/services/course/section';
import {
ZCourseSectionCreate,
ZCourseSectionGetParam,
+172
View File
@@ -0,0 +1,172 @@
/**
* HLS playback streaming.
*
* Same URL shape as the production Cloudflare Worker that fronts the
* tenant-router (`/hls/{assetId}/{path}`). In production the Worker
* intercepts these requests at the edge and serves directly from an R2
* binding, so Render is never in the bytestream. This Hono route only
* fires in environments where the Worker isn't in front of the api —
* primarily local development, but it also acts as a defensive fallback
* if the Worker route ever misfires.
*
* Auth: standard session via `authMiddleware`. We then verify the
* caller's session belongs to the same organization that owns the
* asset. No separate `cio_hls` HMAC cookie is needed here — that's only
* how the Worker authorizes requests in production.
*/
import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
import { getAssetById } from '@cio/db/queries/assets';
import { getS3Client, getStorageConfig } from '@cio/core/config/storage';
import { HLS_COOKIE_NAME, verifyHlsToken } from '@cio/core/services/assets/assets';
import { resolveTrustedBrowserOriginForCors } from '@api/utils/origins';
import { getCookie } from 'hono/cookie';
import { Hono } from '@api/utils/hono';
const HLS_CONTENT_TYPES: Record<string, string> = {
m3u8: 'application/vnd.apple.mpegurl',
ts: 'video/mp2t',
m4s: 'video/iso.segment',
mp4: 'video/mp4',
m4a: 'audio/mp4',
aac: 'audio/aac'
};
function contentTypeFor(path: string): string {
const dot = path.lastIndexOf('.');
if (dot === -1) return 'application/octet-stream';
const ext = path.slice(dot + 1).toLowerCase();
return HLS_CONTENT_TYPES[ext] ?? 'application/octet-stream';
}
export const hlsRouter = new Hono()
.options('/:assetId/*', (c) => {
const origin = c.req.header('origin');
let corsHeaders: Record<string, string> = {};
if (origin) {
const allowed = resolveTrustedBrowserOriginForCors(origin);
if (allowed) {
corsHeaders = {
'Access-Control-Allow-Origin': allowed,
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '600',
'Cross-Origin-Resource-Policy': 'cross-origin'
};
}
}
return new Response(null, { status: 204, headers: corsHeaders });
})
/**
* GET/HEAD /hls/:assetId/* — stream an HLS manifest or segment from R2.
*
* No `authMiddleware` here — public lessons play through the same route
* using only the HMAC cookie. The handler validates either the cookie
* or org-member session itself.
*/
.on(['GET', 'HEAD'], '/:assetId/*', async (c) => {
const origin = c.req.header('origin');
let corsHeaders: Record<string, string> = {};
if (origin) {
const allowed = resolveTrustedBrowserOriginForCors(origin);
if (allowed) {
corsHeaders = {
'Access-Control-Allow-Origin': allowed,
'Access-Control-Allow-Credentials': 'true',
// Override the `Cross-Origin-Resource-Policy: same-origin` that
// app-wide `secureHeaders()` sets — the dashboard origin needs to
// load `<video>` segments from this api origin in local dev /
// self-hosted, and CORP=same-origin blocks the response even
// when CORS allows the request.
'Cross-Origin-Resource-Policy': 'cross-origin'
};
}
}
const withCors = (body: BodyInit | null, status: number, extraHeaders?: Record<string, string>) =>
new Response(body, { status, headers: { ...corsHeaders, ...extraHeaders } });
const assetId = c.req.param('assetId');
if (!assetId) return withCors(null, 400, { 'Content-Type': 'application/json' });
const fullPath = c.req.path;
const marker = `/hls/${assetId}/`;
const idx = fullPath.indexOf(marker);
const relative = idx === -1 ? '' : fullPath.slice(idx + marker.length);
if (!relative || relative.includes('..')) {
return withCors(null, 400, { 'Content-Type': 'application/json' });
}
let authorized = false;
let denyReason: 'no_token_or_session' | 'token_invalid' | 'session_no_role' | 'asset_missing' =
'no_token_or_session';
const token = getCookie(c, HLS_COOKIE_NAME);
if (token) {
const verified = await verifyHlsToken(token);
if (verified?.assetId === assetId) {
authorized = true;
} else {
denyReason = 'token_invalid';
}
}
if (!authorized) {
const orgRoles = (c.get('orgRoles') as Record<string, number> | undefined) ?? {};
if (Object.keys(orgRoles).length) {
const asset = await getAssetById(assetId);
if (!asset) {
denyReason = 'asset_missing';
} else if (orgRoles[asset.organizationId] !== undefined) {
// role 0 still counts as membership — match `orgMemberMiddleware`'s
// `roleId === undefined` rejection rule instead of a truthy check.
authorized = true;
} else {
denyReason = 'session_no_role';
}
}
}
if (!authorized) {
if (process.env.NODE_ENV !== 'production') {
console.warn(
`[hls] 403 for asset=${assetId} reason=${denyReason} cookiePresent=${Boolean(token)} hasSession=${Boolean(c.get('user'))}`
);
}
return withCors(null, 403, { 'Content-Type': 'application/json' });
}
const config = getStorageConfig();
const client = getS3Client();
const objectKey = `${assetId}/${relative}`;
try {
if (c.req.method === 'HEAD') {
const head = await client.send(new HeadObjectCommand({ Bucket: config.bucketVideos, Key: objectKey }));
const headHeaders: Record<string, string> = {
'Content-Type': contentTypeFor(relative),
'Cache-Control': relative.endsWith('.m3u8') ? 'private, max-age=10' : 'private, max-age=31536000, immutable'
};
if (head.ContentLength != null) headHeaders['Content-Length'] = String(head.ContentLength);
return withCors(null, 200, headHeaders);
}
const result = await client.send(new GetObjectCommand({ Bucket: config.bucketVideos, Key: objectKey }));
if (!result.Body) {
return withCors(null, 502);
}
const headers: Record<string, string> = {
'Content-Type': contentTypeFor(relative),
'Cache-Control': relative.endsWith('.m3u8') ? 'private, max-age=10' : 'private, max-age=31536000, immutable'
};
if (result.ContentLength != null) headers['Content-Length'] = String(result.ContentLength);
return withCors(result.Body as unknown as ReadableStream, 200, headers);
} catch (error) {
if (error instanceof Error && error.name === 'NoSuchKey') {
return withCors(null, 404);
}
console.error('hls stream error:', error);
return withCors(null, 500);
}
});
+1
View File
@@ -0,0 +1 @@
export * from './hls';
+66
View File
@@ -2,9 +2,11 @@ import { ZCourseInviteTokenParam } from '@cio/utils/validation/course/invite';
import { previewStudentInvite } from '@api/services/course/invite';
import { ZOrganizationInviteTokenParam } from '@cio/utils/validation/organization/invite';
import {
acceptLinkInvite,
acceptOrganizationInvite,
acceptOrganizationInviteById,
getPendingOrgInviteForUser,
previewLinkInvite,
previewOrganizationInvite
} from '@api/services/organization/invite';
@@ -42,6 +44,24 @@ const acceptOrganizationInviteRateLimit = createRateLimiter({
}
});
const previewLinkInviteRateLimit = createRateLimiter({
windowMs: 60 * 1000,
maxRequests: 30,
message: 'Too many link invite preview requests. Please try again later.',
keyGenerator: (c) => `link_invite_preview:${extractClientIp(c)}:${c.req.param('token')}`
});
const acceptLinkInviteRateLimit = createRateLimiter({
windowMs: 60 * 60 * 1000,
maxRequests: 20,
message: 'Too many link invite join attempts. Please try again later.',
keyGenerator: (c) => {
const user = c.get('user');
const actor = user?.id ? `user:${user.id}` : `ip:${extractClientIp(c)}`;
return `link_invite_accept:${actor}:${c.req.param('token')}`;
}
});
const pendingInviteRateLimit = createRateLimiter({
windowMs: 60 * 1000,
maxRequests: 30,
@@ -203,4 +223,50 @@ export const inviteRouter = new Hono()
return handleError(c, error, 'Failed to accept organization invite');
}
}
)
/**
* GET /invite/link/:token/preview
* Server-only preview for link invites (API key)
*/
.get(
'/link/:token/preview',
apiKeyMiddleware,
previewLinkInviteRateLimit,
zValidator('param', ZOrganizationInviteTokenParam),
async (c) => {
try {
const { token } = c.req.valid('param');
const preview = await previewLinkInvite(token, {
ipAddress: extractClientIp(c),
userAgent: c.req.header('user-agent') || null
});
return c.json({ success: true, data: preview }, 200);
} catch (error) {
return handleError(c, error, 'Failed to load link invite');
}
}
)
/**
* POST /invite/link/:token/accept
* Authenticated link invite acceptance — adds the user to the org
*/
.post(
'/link/:token/accept',
authMiddleware,
acceptLinkInviteRateLimit,
zValidator('param', ZOrganizationInviteTokenParam),
async (c) => {
try {
const { token } = c.req.valid('param');
const user = c.get('user')!;
const result = await acceptLinkInvite(
token,
{ id: user.id, email: user.email },
{ ipAddress: extractClientIp(c), userAgent: c.req.header('user-agent') || null }
);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to accept link invite');
}
}
);
+32 -1
View File
@@ -1,8 +1,13 @@
import { ZPublicCourseBySlugParam, ZPublicCourseItemParam } from '@cio/utils/validation/course';
import { getPublicCourseItemService, getPublicCourseTreeService } from '@api/services/course/public-course';
import {
getPublicCourseItemService,
getPublicCourseTreeService,
issuePublicHlsCookieService
} from '@api/services/course/public-course';
import { Hono } from '@api/utils/hono';
import { handleError } from '@api/utils/errors';
import { setCookie } from 'hono/cookie';
import { zValidator } from '@hono/zod-validator';
/**
@@ -52,4 +57,30 @@ export const publicCourseRouter = new Hono()
} catch (error) {
return handleError(c, error, 'Failed to load public course item');
}
})
/**
* POST /org-site/course/:courseSlug/item/:itemSlug/hls-cookie
* Anonymous-safe. Verifies that the public lesson exposes an HLS asset
* and mints the short-lived `cio_hls` cookie the tenant-router Worker
* (prod) and API streaming route (local) both validate before serving
* segments. Caller is expected to call this once per lesson page load
* before attaching hls.js.
*/
.post('/:courseSlug/item/:itemSlug/hls-cookie', zValidator('param', ZPublicCourseItemParam), async (c) => {
try {
const { courseSlug, itemSlug } = c.req.valid('param');
const result = await issuePublicHlsCookieService(courseSlug, itemSlug);
setCookie(c, result.cookieName, result.token, {
path: '/',
httpOnly: true,
secure: true,
sameSite: 'Lax',
maxAge: result.maxAgeSeconds
});
return c.json({ success: true as const, data: { expiresAt: result.expiresAt } });
} catch (error) {
return handleError(c, error, 'Failed to issue HLS cookie');
}
});
+231 -1
View File
@@ -6,23 +6,39 @@ import {
ZAssetGetParam,
ZAssetListQuery,
ZAssetStorageQuery,
ZAssetThumbnailSelect,
ZAssetUpdate,
ZAssetUsageParams,
ZBatchPresignHls,
ZBatchPresignHls1080,
ZFinalizeHls1080,
ZFinalizeHlsAsset,
ZInitHlsAsset,
ZYouTubeMetadataQuery
} from '@cio/utils/validation/assets';
import { ZUpdateTranscript } from '@cio/utils/validation/media';
import {
abortHlsAssetService,
attachAssetService,
batchPresignHlsService,
batchPresignHls1080Service,
createAssetFromUploadService,
deleteAssetService,
detachAssetService,
exportOrganizationAssetsService,
finalizeHls1080Service,
finalizeHlsAssetService,
getAssetService,
getAssetUsageGraphService,
getOrganizationAssetStorageService,
getTranscriptForOrganizationAssetService,
getYouTubeMetadataService,
initHlsAssetService,
issueHlsCookieService,
listOrganizationAssetsService,
updateAssetService
selectAssetThumbnailService,
updateAssetService,
updateTranscriptForOrganizationAssetService
} from '@api/services/assets';
import { Hono } from '@api/utils/hono';
@@ -31,6 +47,7 @@ import { handleError } from '@api/utils/errors';
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { orgMemberMiddleware } from '@api/middlewares/org-member';
import { zValidator } from '@hono/zod-validator';
import { setCookie } from 'hono/cookie';
export const assetsRouter = new Hono()
/**
@@ -82,6 +99,160 @@ export const assetsRouter = new Hono()
return handleError(c, error, 'Failed to create asset');
}
})
/**
* POST /organization/assets/hls/init
* Reserve an assetId for an HLS upload. Returns the asset id and the
* key prefix the browser encoder should write segments under.
*/
.post('/hls/init', authMiddleware, orgMemberMiddleware, zValidator('json', ZInitHlsAsset), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const user = c.get('user')!;
const data = c.req.valid('json');
const result = await initHlsAssetService(orgId, user.id, data);
return c.json({ success: true, data: result }, 201);
} catch (error) {
return handleError(c, error, 'Failed to init HLS asset');
}
})
/**
* POST /organization/assets/:assetId/hls/presign
* Batch-presign PUT URLs for segment/playlist/audio/thumbnail uploads.
*/
.post(
'/:assetId/hls/presign',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZAssetGetParam),
zValidator('json', ZBatchPresignHls),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { assetId } = c.req.valid('param');
const data = c.req.valid('json');
const result = await batchPresignHlsService(orgId, assetId, data);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to presign HLS uploads');
}
}
)
/**
* POST /organization/assets/:assetId/hls/finalize
* Flip an HLS asset from `processing` to `active` once the browser
* encoder has written everything and persist its metadata.
*/
.post(
'/:assetId/hls/finalize',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZAssetGetParam),
zValidator('json', ZFinalizeHlsAsset),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const user = c.get('user')!;
const { assetId } = c.req.valid('param');
const data = c.req.valid('json');
const result = await finalizeHlsAssetService(orgId, assetId, user.id, data);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to finalize HLS asset');
}
}
)
/**
* POST /organization/assets/:assetId/hls/1080/presign
* Batch-presign PUT URLs for a manual p1080 rendition on an active HLS asset.
*/
.post(
'/:assetId/hls/1080/presign',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZAssetGetParam),
zValidator('json', ZBatchPresignHls1080),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { assetId } = c.req.valid('param');
const data = c.req.valid('json');
const result = await batchPresignHls1080Service(orgId, assetId, data);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to presign 1080p HLS uploads');
}
}
)
/**
* POST /organization/assets/:assetId/hls/1080/finalize
* Persist p1080 rendition metadata after the browser encoder updates the manifest.
*/
.post(
'/:assetId/hls/1080/finalize',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZAssetGetParam),
zValidator('json', ZFinalizeHls1080),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { assetId } = c.req.valid('param');
const data = c.req.valid('json');
const result = await finalizeHls1080Service(orgId, assetId, data);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to finalize 1080p HLS rendition');
}
}
)
/**
* DELETE /organization/assets/:assetId/hls
* Abort a `processing` HLS asset: best-effort delete R2 objects under
* `videos/{assetId}/` and remove the asset row. Called by the encoder
* when it errors or is aborted mid-upload, so we don't leak stale
* `processing` rows + partial segment trees.
*/
.delete('/:assetId/hls', authMiddleware, orgMemberMiddleware, zValidator('param', ZAssetGetParam), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { assetId } = c.req.valid('param');
const deleted = await abortHlsAssetService(orgId, assetId);
return c.json({ success: true, data: deleted }, 200);
} catch (error) {
return handleError(c, error, 'Failed to abort HLS asset');
}
})
/**
* POST /organization/assets/:assetId/hls/cookie
* After entitlement check, set a 15-minute HMAC-signed cookie scoped to
* this asset id. The tenant-router Worker verifies the cookie locally
* on every /hls/{assetId}/* segment request — no round-trip back to Render.
*/
.post('/:assetId/hls/cookie', authMiddleware, orgMemberMiddleware, zValidator('param', ZAssetGetParam), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { assetId } = c.req.valid('param');
const { token, expiresAt, cookieName, maxAgeSeconds } = await issueHlsCookieService(orgId, assetId);
setCookie(c, cookieName, token, {
path: '/',
httpOnly: true,
secure: true,
sameSite: 'Lax',
maxAge: maxAgeSeconds
});
return c.json({ success: true, data: { expiresAt } }, 200);
} catch (error) {
return handleError(c, error, 'Failed to issue HLS cookie');
}
})
/**
* GET /organization/assets/export
* Export organization assets with usage graph
@@ -172,6 +343,36 @@ export const assetsRouter = new Hono()
return handleError(c, error, 'Failed to fetch transcript');
}
})
/**
* PUT /organization/assets/:assetId/transcript
* Update transcript segment text (org admins only). Captions re-render from
* these segments — no R2 write.
*/
.put(
'/:assetId/transcript',
authMiddleware,
orgAdminMiddleware,
zValidator('param', ZAssetGetParam),
zValidator('json', ZUpdateTranscript),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { assetId } = c.req.valid('param');
const { segments } = c.req.valid('json');
const data = await updateTranscriptForOrganizationAssetService(orgId, assetId, segments);
return c.json(
{
success: true,
data
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to update transcript');
}
}
)
/**
* GET /organization/assets/:assetId
* Get a single asset by id
@@ -222,6 +423,35 @@ export const assetsRouter = new Hono()
}
}
)
/**
* PUT /organization/assets/:assetId/thumbnail
* Choose a thumbnail for an asset (from generated candidates or a fresh upload).
*/
.put(
'/:assetId/thumbnail',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZAssetGetParam),
zValidator('json', ZAssetThumbnailSelect),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { assetId } = c.req.valid('param');
const { thumbnailUrl } = c.req.valid('json');
const updated = await selectAssetThumbnailService(orgId, assetId, thumbnailUrl);
return c.json(
{
success: true,
data: updated
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to update asset thumbnail');
}
}
)
/**
* DELETE /organization/assets/:assetId
* Delete an unused asset
@@ -2,6 +2,7 @@ import {
ZAssignAudienceCourses,
ZAudienceInviteByEmail,
ZCancelOrgPlan,
ZCreateLinkInvite,
ZCreateOrgPlan,
ZCreateOrganization,
ZGetAudienceQuery,
@@ -14,6 +15,7 @@ import {
ZInviteTeamMembers,
ZLMSExercisesParam,
ZRemoveTeamMember,
ZToggleLinkInvite,
ZUpdateOrgPlan,
ZUpdateOrganization
} from '@cio/utils/validation/organization';
@@ -57,7 +59,12 @@ import { automationRouter } from '@api/routes/organization/automation';
import { courseImportRouter } from '@api/routes/organization/course-import';
import { getLMSExercisesService } from '@api/services/exercise';
import { handleError } from '@api/utils/errors';
import { inviteTeamMembers } from '@api/services/organization/invite';
import {
fetchOrgLinkInvite,
getOrCreateLinkInvite,
inviteTeamMembers,
toggleOrgLinkInvite
} from '@api/services/organization/invite';
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { orgMemberMiddleware } from '@api/middlewares/org-member';
import { orgMemberOrAutomationKeyMiddleware } from '@api/middlewares/org-member-or-automation-key';
@@ -178,6 +185,49 @@ export const organizationRouter = new Hono()
return handleError(c, error, 'Failed to invite team members');
}
})
/**
* GET /organization/link-invite
* Returns the current org link invite (null if none created yet)
*/
.get('/link-invite', authMiddleware, orgAdminMiddleware, async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const data = await fetchOrgLinkInvite(orgId);
return c.json({ success: true, data }, 200);
} catch (error) {
return handleError(c, error, 'Failed to fetch link invite');
}
})
/**
* POST /organization/link-invite
* Get-or-create the org link invite
*/
.post('/link-invite', authMiddleware, orgAdminMiddleware, zValidator('json', ZCreateLinkInvite), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const user = c.get('user')!;
const { roleId } = c.req.valid('json');
const data = await getOrCreateLinkInvite(orgId, roleId, user.id);
return c.json({ success: true, data }, 200);
} catch (error) {
return handleError(c, error, 'Failed to create link invite');
}
})
/**
* PATCH /organization/link-invite
* Enable or disable the org link invite
*/
.patch('/link-invite', authMiddleware, orgAdminMiddleware, zValidator('json', ZToggleLinkInvite), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const user = c.get('user')!;
const { isRevoked } = c.req.valid('json');
const data = await toggleOrgLinkInvite(orgId, isRevoked, user.id);
return c.json({ success: true, data }, 200);
} catch (error) {
return handleError(c, error, 'Failed to update link invite');
}
})
/**
* DELETE /organization/team/:memberId
* Removes a team member from an organization
@@ -332,12 +382,17 @@ export const organizationRouter = new Hono()
*/
.get('/courses/public', zValidator('query', ZGetCoursesBySiteName), async (c) => {
try {
const { siteName, tags } = c.req.valid('query');
const { siteName, tags, page, limit } = c.req.valid('query');
const tagSlugs = tags
?.split(',')
.map((value) => value.trim())
.filter(Boolean);
const courses = await getPublicCourses(siteName, tagSlugs && tagSlugs.length > 0 ? tagSlugs : undefined);
const pagination = page !== undefined || limit !== undefined ? { page, limit } : undefined;
const courses = await getPublicCourses(
siteName,
tagSlugs && tagSlugs.length > 0 ? tagSlugs : undefined,
pagination
);
return c.json(
{
+1
View File
@@ -0,0 +1 @@
export * from './transcripts';
@@ -0,0 +1,101 @@
/**
* Transcript VTT proxy.
*
* `<track>` elements inside `<video crossorigin="anonymous">` can only
* load resources that return CORS headers. We serve the VTT through the
* api the same way we do HLS segments, rendering it on the fly from the
* stored transcript segments (the structured source of truth) so edits
* are reflected immediately without an R2 round trip. The path is
* same-origin behind the tenant-router Worker in prod and a CORS-allowed
* cross-origin call in local dev.
*
* Auth mirrors `/hls/*`:
* 1. Valid `cio_hls` HMAC cookie whose `aid` matches the assetId, OR
* 2. Authenticated session with org-member access to the asset's org.
*/
import { getAssetById } from '@cio/db/queries/assets';
import { getMediaTranscriptByAsset } from '@cio/db/queries';
import { segmentsToWebVtt } from '@cio/utils/functions';
import { HLS_COOKIE_NAME, verifyHlsToken } from '@cio/core/services/assets/assets';
import { resolveTrustedBrowserOriginForCors } from '@api/utils/origins';
import { getCookie } from 'hono/cookie';
import { Hono } from '@api/utils/hono';
export const transcriptsRouter = new Hono()
.options('/:assetId/track.vtt', (c) => {
const origin = c.req.header('origin');
let corsHeaders: Record<string, string> = {};
if (origin) {
const allowed = resolveTrustedBrowserOriginForCors(origin);
if (allowed) {
corsHeaders = {
'Access-Control-Allow-Origin': allowed,
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '600',
'Cross-Origin-Resource-Policy': 'cross-origin'
};
}
}
return new Response(null, { status: 204, headers: corsHeaders });
})
/**
* GET/HEAD /transcripts/:assetId/track.vtt — render a transcript VTT
* from the stored segments with cross-origin CORS so a `<track crossorigin>`
* element in the dashboard can load it.
*/
.on(['GET', 'HEAD'], '/:assetId/track.vtt', async (c) => {
const assetId = c.req.param('assetId');
if (!assetId) return c.json({ success: false, message: 'Missing assetId' }, 400);
const origin = c.req.header('origin');
let corsHeaders: Record<string, string> = {};
if (origin) {
const allowed = resolveTrustedBrowserOriginForCors(origin);
if (allowed) {
corsHeaders = {
'Access-Control-Allow-Origin': allowed,
'Access-Control-Allow-Credentials': 'true',
'Cross-Origin-Resource-Policy': 'cross-origin'
};
}
}
const withCors = (body: BodyInit | null, status: number, extraHeaders?: Record<string, string>) =>
new Response(body, { status, headers: { ...corsHeaders, ...extraHeaders } });
const asset = await getAssetById(assetId);
if (!asset) return withCors(null, 404);
let authorized = false;
const token = getCookie(c, HLS_COOKIE_NAME);
if (token) {
const verified = await verifyHlsToken(token);
if (verified?.assetId === assetId) authorized = true;
}
if (!authorized) {
const orgRoles = (c.get('orgRoles') as Record<string, number> | undefined) ?? {};
if (orgRoles[asset.organizationId]) authorized = true;
}
if (!authorized) {
return withCors(null, 403);
}
const transcript = await getMediaTranscriptByAsset(assetId, asset.organizationId);
if (!transcript) return withCors(null, 404);
const vttHeaders = {
'Content-Type': 'text/vtt; charset=utf-8',
'Cache-Control': 'private, no-cache'
};
if (c.req.method === 'HEAD') {
return withCors(null, 200, vttHeaders);
}
const vtt = segmentsToWebVtt(transcript.segments);
return withCors(vtt, 200, vttHeaders);
});
+1 -1
View File
@@ -1,5 +1,5 @@
import { Hono } from '@api/utils/hono';
import { env } from '@api/config/env';
import { env } from '@cio/core/config/env';
import { handleError } from '@api/utils/errors';
import { z } from 'zod';
+1
View File
@@ -2,3 +2,4 @@ export * from './profile';
export * from './workspaces';
export * from './quota';
export * from './grace';
export * from './view-as-student';
+5 -3
View File
@@ -10,7 +10,7 @@ import { getProfileByEmail, getProfileById, updateProfile } from '@cio/db/querie
import type { OrganizationWithMemberAndPlans } from '@cio/db/queries/organization/types';
import { ROLE } from '@cio/utils/constants';
import type { TProfile } from '@cio/db/types';
import { env } from '@api/config/env';
import { env } from '@cio/core/config/env';
import { getLicenseStatus } from '@api/services/license';
export type GetAccountDataResult = {
@@ -26,10 +26,12 @@ export type GetAccountDataResult = {
* @returns Account data with profile and organizations
*/
export async function getAccountData(userId: string): Promise<GetAccountDataResult> {
const isSelfHosted = env.PUBLIC_IS_SELFHOSTED === 'true';
let [profile, organizations, licenseStatus] = await Promise.all([
getProfileById(userId),
getOrganizationByProfileId(userId),
getLicenseStatus()
isSelfHosted ? getLicenseStatus() : Promise.resolve({ valid: true, features: [] as string[] })
]);
if (!profile) {
@@ -59,7 +61,7 @@ export async function getAccountData(userId: string): Promise<GetAccountDataResu
return {
profile,
organizations,
licenseFeatures: licenseStatus.features
licenseFeatures: isSelfHosted ? licenseStatus.features : []
};
}
@@ -0,0 +1,24 @@
import { mintLoginLinkToken } from '@cio/db/auth';
/**
* Login-link tokens for the "View as student" handoff are consumed within seconds
* of being minted (the client immediately navigates to the org domain), so they
* are intentionally short-lived.
*/
const VIEW_AS_STUDENT_TTL_MINUTES = 5;
/**
* Mint a short-lived login-link token for the **current** user so they can be
* auto-signed-in on the org domain (the "View as student" cross-host handoff).
*
* Self sign-in only: the caller must pass the already-authenticated user. We never
* accept a target user id from the client, so this can't be used to impersonate
* anyone else.
*/
export async function createViewAsStudentToken(user: { id: string; email: string }): Promise<string> {
return mintLoginLinkToken({
userId: user.id,
email: user.email,
ttlMinutes: VIEW_AS_STUDENT_TTL_MINUTES
});
}
+7 -1
View File
@@ -12,7 +12,13 @@ import {
const MAX_PERSISTED_TOOL_OUTPUT_CHARS = 2000;
const DROPPED_PART_TYPES = new Set(['step-start', 'reasoning']);
const TOOLS_WITH_FULL_OUTPUT = new Set(['generate_course_plan', 'ask_template_questions', 'fetch_documentation_url']);
const TOOLS_WITH_FULL_OUTPUT = new Set([
'generate_course_plan',
'ask_template_questions',
'fetch_documentation_url',
'get_course_structure',
'get_exercise_details'
]);
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
+1 -1
View File
@@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto';
import { AppError } from '@api/utils/errors';
import { ErrorCodes } from '@cio/utils/constants';
import { getChatConversation, saveChatMessages } from '@api/services/agent/chat-history';
import { summarizeConversation, type SummarizeOptions } from '@api/services/agent/summarize';
import { summarizeConversation, type SummarizeOptions } from '@cio/core/services/agent/summarize';
export async function compactConversation(conversationId: string, userId: string): Promise<{ messages: unknown[] }> {
const conversation = await getChatConversation(conversationId, userId);
+119 -4
View File
@@ -15,6 +15,7 @@ import {
import { isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group';
import { getCourseOrganizationId } from '@cio/db/queries/tag';
import type { TAiAgentRun, TAiAgentRunEvent, TAiAgentRunStatus, TAiAgentRunStep } from '@cio/db/types';
import { QUEUE_NAMES, enqueueAgentCourseGenerationRun, getQueue, isRedisConfigured } from '@cio/jobs';
import { AppError } from '@api/utils/errors';
const ACTIVE_RUN_STATUSES = new Set<TAiAgentRunStatus>(['queued', 'running', 'waiting_for_input', 'paused']);
@@ -90,6 +91,106 @@ async function buildAgentRunDetail(run: TAiAgentRun): Promise<AgentRunDetail> {
return { run, steps, events };
}
function getBullmqJobId(run: TAiAgentRun): string | null {
const bullmq = run.executionCursor.bullmq;
if (!bullmq || typeof bullmq !== 'object') {
return null;
}
const jobId = (bullmq as Record<string, unknown>).jobId;
return typeof jobId === 'string' && jobId.length > 0 ? jobId : null;
}
async function removeQueuedBullmqJob(run: TAiAgentRun): Promise<void> {
if (!isRedisConfigured()) return;
const jobId = getBullmqJobId(run);
if (!jobId) return;
try {
const removed = await getQueue(QUEUE_NAMES.agentCourseGeneration).remove(jobId);
await createAgentRunEvent({
runId: run.id,
eventType: 'run.queue_cancel_checked',
message: removed === 1 ? 'Queued BullMQ job removed' : 'BullMQ job was already active or missing',
payload: { jobId, removed }
});
} catch (error) {
await createAgentRunEvent({
runId: run.id,
eventType: 'run.queue_cancel_failed',
message: 'Could not remove queued BullMQ job; worker will stop at the next checkpoint',
payload: { jobId, error: error instanceof Error ? error.message : String(error) }
});
}
}
async function enqueueAgentRun(run: TAiAgentRun, reason: 'created' | 'resumed' | 'retried'): Promise<TAiAgentRun> {
if (!isRedisConfigured()) {
await createAgentRunEvent({
runId: run.id,
eventType: 'run.enqueue_skipped',
message: 'Redis is not configured; run state was saved but no BullMQ job was enqueued',
payload: { reason }
});
return run;
}
try {
const jobId = await enqueueAgentCourseGenerationRun(
{
runId: run.id,
requestedByUserId: run.userId,
organizationId: run.orgId
},
{ attempt: run.attempt }
);
const updatedRun = await updateOwnedAgentRun(run.id, run.userId, {
status: 'queued',
executionCursor: {
...run.executionCursor,
bullmq: {
queue: 'agent-course-generation',
jobId: jobId ?? null,
enqueuedAt: new Date().toISOString(),
reason
}
}
});
await createAgentRunEvent({
runId: run.id,
eventType: 'run.enqueued',
message: 'Run enqueued for background course generation',
payload: { jobId: jobId ?? null, reason }
});
return updatedRun;
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to enqueue agent run';
await updateOwnedAgentRun(run.id, run.userId, {
status: 'failed',
phase: 'failed',
lastError: { code: 'AGENT_RUN_ENQUEUE_FAILED', message },
finishedAt: new Date().toISOString()
});
await createAgentRunEvent({
runId: run.id,
eventType: 'run.enqueue_failed',
message: 'Failed to enqueue background course generation',
payload: { reason, error: { code: 'AGENT_RUN_ENQUEUE_FAILED', message } }
});
throw new AppError(message, 'AGENT_RUN_ENQUEUE_FAILED', 500);
}
}
async function updateOwnedAgentRun(
runId: string,
userId: string,
@@ -132,7 +233,9 @@ export async function createAgentRunState(input: CreateAgentRunStateInput): Prom
payload: { phase: run.phase, status: run.status }
});
return buildAgentRunDetail(run);
const enqueuedRun = await enqueueAgentRun(run, 'created');
return buildAgentRunDetail(enqueuedRun);
}
export async function listAgentRunStates(courseId: string, userId: string, orgId: string): Promise<TAiAgentRun[]> {
@@ -158,6 +261,8 @@ export async function cancelAgentRunState(runId: string, userId: string, orgId:
throw new AppError('Completed agent runs cannot be canceled', 'AGENT_RUN_ALREADY_COMPLETED', 409);
}
await removeQueuedBullmqJob(run);
const now = new Date().toISOString();
const updatedRun = await updateOwnedAgentRun(runId, userId, {
status: 'canceled',
@@ -181,10 +286,16 @@ export async function cancelAgentRunState(runId: string, userId: string, orgId:
export async function resumeAgentRunState(runId: string, userId: string, orgId: string): Promise<AgentRunDetail> {
const run = await getOwnedAgentRun(runId, userId, orgId);
if (run.status === 'queued' || run.status === 'running') {
if (run.status === 'running') {
return buildAgentRunDetail(run);
}
if (run.status === 'queued') {
const enqueuedRun = await enqueueAgentRun(run, 'resumed');
return buildAgentRunDetail(enqueuedRun);
}
if (run.status === 'completed') {
throw new AppError('Completed agent runs cannot be resumed', 'AGENT_RUN_ALREADY_COMPLETED', 409);
}
@@ -212,7 +323,9 @@ export async function resumeAgentRunState(runId: string, userId: string, orgId:
payload: { previousStatus: run.status, phase: run.phase }
});
return buildAgentRunDetail(updatedRun);
const enqueuedRun = await enqueueAgentRun(updatedRun, 'resumed');
return buildAgentRunDetail(enqueuedRun);
}
export async function retryAgentRunState(runId: string, userId: string, orgId: string): Promise<AgentRunDetail> {
@@ -243,7 +356,9 @@ export async function retryAgentRunState(runId: string, userId: string, orgId: s
payload: { previousAttempt: run.attempt, nextAttempt: updatedRun.attempt }
});
return buildAgentRunDetail(updatedRun);
const enqueuedRun = await enqueueAgentRun(updatedRun, 'retried');
return buildAgentRunDetail(enqueuedRun);
}
export async function appendAgentRunInstructionState(
+5 -5
View File
@@ -6,12 +6,12 @@ import { db } from '@cio/db';
import * as schema from '@cio/db/schema';
import type { AiTutorSettings } from '@cio/ai-assistant';
import { getLesson } from '@api/services/lesson/lesson';
import { getExercise } from '@api/services/exercise/exercise';
import { listCourseSections } from '@api/services/course/section';
import { getLesson } from '@cio/core/services/lesson/lesson';
import { getExercise } from '@cio/core/services/exercise/exercise';
import { listCourseSections } from '@cio/core/services/course/section';
import { AppError } from '@api/utils/errors';
import { AgentEvent, trackAgentEvent } from '@api/utils/tinybird';
import { verifyExerciseBelongsToCourse, verifyLessonBelongsToCourse } from './chat-context';
import { AgentEvent, trackAgentEvent } from '@cio/core/utils/tinybird';
import { verifyExerciseBelongsToCourse, verifyLessonBelongsToCourse } from '@cio/core/services/agent/chat-context';
/**
* Student agent tools — read-only, course-scoped.
+1 -1
View File
@@ -16,7 +16,7 @@ import {
import { AppError } from '@api/utils/errors';
import { getEffectiveAiTutorSettings } from './tutor-config';
import { enforceTokenBalance } from './usage';
import { enforceTokenBalance } from '@cio/core/services/agent/usage';
export const STUDENT_TUTOR_CAP = STUDENT_TUTOR_MONTHLY_CAP;
+2 -2
View File
@@ -5,8 +5,8 @@ import {
selectPopularCourseTypes
} from '@cio/db/queries/analytics';
import { DASH_ANALYTICS_TTL_SECONDS, dashAnalyticsKey } from '@api/utils/redis/key-generators';
import { env } from '@api/config/env';
import { logRedisUnavailableOnce, redis } from '@api/utils/redis/redis';
import { env } from '@cio/core/config/env';
import { logRedisUnavailableOnce, redis } from '@cio/core/utils/redis/redis';
export type LandingStats = {
totals: {
-539
View File
@@ -1,539 +0,0 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import type {
TAssetAttach,
TAssetCreateUpload,
TAssetDetach,
TAssetExportQuery,
TAssetListQuery,
TAssetStorageQuery,
TAssetUpdate,
TYouTubeMetadataQuery
} from '@cio/utils/validation/assets';
import type { TTranscriptResponse } from '@cio/utils/validation/media';
import {
countAssetUsagesByAsset,
createAssetUsage,
createOrGetAssetByStorageKey,
deleteAsset,
deleteAssetUsage,
getAssetById,
getAssetStorageSummaryByOrg,
listAssetsByOrg,
listAssetsForExport,
listAssetUsagesByAsset,
updateAsset
} from '@cio/db/queries/assets';
import { getMediaTranscriptByAsset } from '@cio/db/queries';
import { generateTranscriptVttPresignedUrl, TRANSCRIPT_VTT_PRESIGN_SECONDS } from '@api/utils/s3';
import { getStorageConfig } from '@api/config/storage';
const YOUTUBE_VIDEO_ID_REGEX = /^[a-zA-Z0-9_-]{11}$/;
type YouTubeOEmbedResponse = {
title?: unknown;
thumbnail_url?: unknown;
};
type YouTubeWatchPageMetadata = {
title: string | null;
durationSeconds: number | null;
thumbnailUrl: string | null;
};
function assertAssetExists<T>(asset: T | null): T {
if (!asset) {
throw new AppError('Asset not found', ErrorCodes.ASSET_NOT_FOUND, 404);
}
return asset;
}
function normalizeUrl(url: string): URL | null {
const trimmed = url.trim();
if (!trimmed) {
return null;
}
try {
return new URL(trimmed);
} catch {
try {
return new URL(`https://${trimmed}`);
} catch {
return null;
}
}
}
function extractYouTubeVideoId(url: string): string | null {
const parsedUrl = normalizeUrl(url);
if (!parsedUrl) {
return null;
}
const host = parsedUrl.hostname.toLowerCase();
let videoId: string | null = null;
if (host === 'youtu.be' || host === 'www.youtu.be') {
const [pathSegment] = parsedUrl.pathname.split('/').filter(Boolean);
videoId = pathSegment ?? null;
} else if (
host === 'youtube.com' ||
host === 'www.youtube.com' ||
host === 'm.youtube.com' ||
host === 'music.youtube.com' ||
host === 'youtube-nocookie.com' ||
host === 'www.youtube-nocookie.com'
) {
if (parsedUrl.pathname === '/watch') {
videoId = parsedUrl.searchParams.get('v');
} else {
const [firstSegment, secondSegment] = parsedUrl.pathname.split('/').filter(Boolean);
if (firstSegment === 'embed' || firstSegment === 'shorts' || firstSegment === 'live') {
videoId = secondSegment ?? null;
}
}
}
if (!videoId || !YOUTUBE_VIDEO_ID_REGEX.test(videoId)) {
return null;
}
return videoId;
}
function decodeHtmlEntities(value: string): string {
return value
.replaceAll('&amp;', '&')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&#x27;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>');
}
function extractMetaContent(html: string, pattern: RegExp): string | null {
const match = html.match(pattern);
const value = match?.[1];
if (!value) {
return null;
}
const decoded = decodeHtmlEntities(value.trim());
return decoded.length ? decoded : null;
}
function extractDurationSeconds(html: string): number | null {
const lengthSecondsMatch = html.match(/"lengthSeconds":"(\d+)"/);
if (lengthSecondsMatch?.[1]) {
const parsed = Number.parseInt(lengthSecondsMatch[1], 10);
return Number.isFinite(parsed) ? parsed : null;
}
const approxDurationMsMatch = html.match(/"approxDurationMs":"(\d+)"/);
if (approxDurationMsMatch?.[1]) {
const parsed = Number.parseInt(approxDurationMsMatch[1], 10);
if (Number.isFinite(parsed) && parsed >= 0) {
return Math.round(parsed / 1000);
}
}
return null;
}
async function fetchYouTubeOEmbed(videoUrl: string): Promise<{ title: string | null; thumbnailUrl: string | null }> {
try {
const response = await fetch(`https://www.youtube.com/oembed?format=json&url=${encodeURIComponent(videoUrl)}`);
if (!response.ok) {
return {
title: null,
thumbnailUrl: null
};
}
const data = (await response.json()) as YouTubeOEmbedResponse;
const title = typeof data.title === 'string' && data.title.trim().length ? data.title.trim() : null;
const thumbnailUrl =
typeof data.thumbnail_url === 'string' && data.thumbnail_url.trim().length ? data.thumbnail_url.trim() : null;
return {
title,
thumbnailUrl
};
} catch {
return {
title: null,
thumbnailUrl: null
};
}
}
async function fetchYouTubeWatchPageMetadata(videoId: string): Promise<YouTubeWatchPageMetadata> {
try {
const response = await fetch(`https://www.youtube.com/watch?v=${videoId}`);
if (!response.ok) {
return {
title: null,
durationSeconds: null,
thumbnailUrl: null
};
}
const html = await response.text();
const title =
extractMetaContent(html, /<meta\s+property="og:title"\s+content="([^"]+)"/i) ??
extractMetaContent(html, /<meta\s+name="title"\s+content="([^"]+)"/i);
const thumbnailUrl = extractMetaContent(html, /<meta\s+property="og:image"\s+content="([^"]+)"/i);
const durationSeconds = extractDurationSeconds(html);
return {
title,
durationSeconds,
thumbnailUrl
};
} catch {
return {
title: null,
durationSeconds: null,
thumbnailUrl: null
};
}
}
export async function listOrganizationAssetsService(orgId: string, query: TAssetListQuery) {
try {
return await listAssetsByOrg(orgId, query);
} catch (error) {
throw new AppError(
error instanceof Error ? error.message : 'Failed to list assets',
ErrorCodes.ASSET_LIST_FAILED,
500
);
}
}
export async function createAssetFromUploadService(orgId: string, profileId: string, data: TAssetCreateUpload) {
try {
const asset = await createOrGetAssetByStorageKey({
organizationId: orgId,
kind: data.kind,
provider: data.provider,
storageProvider: data.storageProvider,
storageKey: data.storageKey ?? null,
sourceUrl: data.sourceUrl ?? null,
mimeType: data.mimeType ?? null,
byteSize: data.byteSize ?? null,
checksum: data.checksum ?? null,
title: data.title ?? null,
description: data.description ?? null,
thumbnailUrl: data.thumbnailUrl ?? null,
durationSeconds: data.durationSeconds ?? null,
aspectRatio: data.aspectRatio ?? null,
isExternal: data.isExternal,
status: 'active',
metadata: data.metadata ?? {},
createdByProfileId: profileId
});
// Fire-and-forget: enqueue lesson-video post-processing for new uploads.
// Errors are logged but never block the asset create response.
if (asset.kind === 'video' && asset.provider === 'upload' && asset.storageKey) {
void enqueueMediaPostProcessingForAsset({
organizationId: orgId,
assetId: asset.id,
storageKey: asset.storageKey,
triggeredByProfileId: profileId
});
}
return asset;
} catch (error) {
throw new AppError(
error instanceof Error ? error.message : 'Failed to create asset',
ErrorCodes.ASSET_CREATE_FAILED,
500
);
}
}
async function enqueueMediaPostProcessingForAsset(input: {
organizationId: string;
assetId: string;
storageKey: string;
triggeredByProfileId: string;
}): Promise<void> {
try {
const { startMediaJob } = await import('@api/services/jobs');
await startMediaJob({
organizationId: input.organizationId,
assetId: input.assetId,
storageKey: input.storageKey,
triggeredByProfileId: input.triggeredByProfileId,
withTranscription: Boolean(process.env.OPENAI_API_KEY)
});
} catch (error) {
console.error('enqueueMediaPostProcessingForAsset failed:', error);
}
}
export async function getAssetService(orgId: string, assetId: string) {
try {
const asset = await getAssetById(assetId, orgId);
return assertAssetExists(asset);
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to fetch asset',
ErrorCodes.ASSET_FETCH_FAILED,
500
);
}
}
export async function updateAssetService(orgId: string, assetId: string, data: TAssetUpdate) {
try {
const updated = await updateAsset(assetId, orgId, data);
return assertAssetExists(updated);
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to update asset',
ErrorCodes.ASSET_UPDATE_FAILED,
500
);
}
}
/**
* Set the chosen thumbnail URL for an asset. Accepts URLs that are either
* (a) one of the asset's auto-generated candidates, or (b) a freshly uploaded
* URL on the org's media bucket. Anything else is rejected so callers can't
* point the asset at an arbitrary remote image.
*/
export async function selectAssetThumbnailService(orgId: string, assetId: string, thumbnailUrl: string) {
try {
const asset = await getAssetById(assetId, orgId);
assertAssetExists(asset);
const candidates = (asset?.thumbnailCandidates ?? []) as string[];
const mediaBase = getStorageConfig().mediaPublicBaseUrl?.replace(/\/$/, '') ?? null;
const isCandidate = candidates.includes(thumbnailUrl);
const isOnMediaBucket = Boolean(mediaBase && thumbnailUrl.startsWith(`${mediaBase}/`));
if (!isCandidate && !isOnMediaBucket) {
throw new AppError(
'Thumbnail URL must be a generated candidate or a media-bucket upload',
ErrorCodes.VALIDATION_ERROR,
400
);
}
const updated = await updateAsset(assetId, orgId, { thumbnailUrl });
return assertAssetExists(updated);
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to update asset thumbnail',
ErrorCodes.ASSET_UPDATE_FAILED,
500
);
}
}
export async function deleteAssetService(orgId: string, assetId: string) {
try {
await getAssetService(orgId, assetId);
const usageCount = await countAssetUsagesByAsset(assetId, orgId);
if (usageCount > 0) {
throw new AppError('Asset is still in use', ErrorCodes.ASSET_IN_USE, 409);
}
const deleted = await deleteAsset(assetId, orgId);
return assertAssetExists(deleted);
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to delete asset',
ErrorCodes.ASSET_DELETE_FAILED,
500
);
}
}
export async function getAssetUsageGraphService(orgId: string, assetId: string) {
try {
await getAssetService(orgId, assetId);
const [usages, usageCount] = await Promise.all([
listAssetUsagesByAsset(assetId, orgId),
countAssetUsagesByAsset(assetId, orgId)
]);
return {
usageCount,
usages
};
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to fetch asset usage',
ErrorCodes.ASSET_USAGE_FETCH_FAILED,
500
);
}
}
export async function attachAssetService(orgId: string, assetId: string, profileId: string, input: TAssetAttach) {
try {
await getAssetService(orgId, assetId);
return await createAssetUsage({
organizationId: orgId,
assetId,
targetType: input.targetType,
targetId: input.targetId,
slotType: input.slotType,
slotKey: input.slotKey ?? null,
position: input.position ?? null,
createdByProfileId: profileId
});
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to attach asset',
ErrorCodes.ASSET_ATTACH_FAILED,
500
);
}
}
export async function detachAssetService(orgId: string, assetId: string, input: TAssetDetach) {
try {
await getAssetService(orgId, assetId);
const detached = await deleteAssetUsage(orgId, assetId, input);
if (!detached) {
throw new AppError('Asset usage not found', ErrorCodes.ASSET_USAGE_NOT_FOUND, 404);
}
return detached;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to detach asset',
ErrorCodes.ASSET_DETACH_FAILED,
500
);
}
}
export async function exportOrganizationAssetsService(orgId: string, query: TAssetExportQuery) {
try {
return await listAssetsForExport(orgId, query);
} catch (error) {
throw new AppError(
error instanceof Error ? error.message : 'Failed to export assets',
ErrorCodes.ASSET_EXPORT_FAILED,
500
);
}
}
export async function getOrganizationAssetStorageService(orgId: string, query: TAssetStorageQuery) {
try {
return await getAssetStorageSummaryByOrg(orgId, query);
} catch (error) {
throw new AppError(
error instanceof Error ? error.message : 'Failed to fetch asset storage summary',
ErrorCodes.ASSET_STORAGE_FETCH_FAILED,
500
);
}
}
export async function getTranscriptForOrganizationAssetService(
orgId: string,
assetId: string
): Promise<TTranscriptResponse | null> {
try {
const asset = assertAssetExists(await getAssetById(assetId));
if (asset.organizationId !== orgId) {
throw new AppError('Asset not found', ErrorCodes.ASSET_NOT_FOUND, 404);
}
const row = await getMediaTranscriptByAsset(assetId, orgId);
if (!row) {
return null;
}
const vttUrl = await generateTranscriptVttPresignedUrl(row.vttStorageKey, row.vttBucket);
const vttUrlExpiresAt = new Date(Date.now() + TRANSCRIPT_VTT_PRESIGN_SECONDS * 1000).toISOString();
return {
language: row.language,
segments: row.segments,
vttUrl,
vttUrlExpiresAt,
durationSeconds: row.durationSeconds ?? null
};
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to fetch transcript',
ErrorCodes.ASSET_FETCH_FAILED,
500
);
}
}
export async function getYouTubeMetadataService(_orgId: string, query: TYouTubeMetadataQuery) {
const videoId = extractYouTubeVideoId(query.url);
if (!videoId) {
throw new AppError('Invalid YouTube URL', ErrorCodes.VALIDATION_ERROR, 400, 'url');
}
const sourceUrl = `https://www.youtube.com/watch?v=${videoId}`;
try {
const [oembed, watchPageMetadata] = await Promise.all([
fetchYouTubeOEmbed(sourceUrl),
fetchYouTubeWatchPageMetadata(videoId)
]);
return {
videoId,
sourceUrl,
title: oembed.title ?? watchPageMetadata.title ?? 'YouTube video',
durationSeconds: watchPageMetadata.durationSeconds,
thumbnailUrl:
oembed.thumbnailUrl ?? watchPageMetadata.thumbnailUrl ?? `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`
};
} catch (error) {
throw new AppError(
error instanceof Error ? error.message : 'Failed to fetch YouTube metadata',
ErrorCodes.ASSET_FETCH_FAILED,
500
);
}
}
+1 -1
View File
@@ -1 +1 @@
export * from './assets';
export * from '@cio/core/services/assets/assets';
@@ -1,7 +1,7 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { env } from '@api/config/env';
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { createCourse, updateCourse } from '@api/services/course/course';
import { env } from '@cio/core/config/env';
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
import { createCourse, updateCourse } from '@cio/core/services/course/course';
import {
createExercise,
deleteExerciseService,
@@ -14,13 +14,13 @@ import {
deleteCourseSectionService,
listCourseSections,
updateCourseSectionService
} from '@api/services/course/section';
} from '@cio/core/services/course/section';
import { createLesson, listLessons, updateLessonService, deleteLessonService } from '@api/services/lesson';
import {
deleteLessonLanguageService,
upsertLessonLanguageService,
updateLessonLanguageService
} from '@api/services/lesson-language';
} from '@cio/core/services/lesson-language';
import {
createCourseImportDraft,
getCourseImportDraftById,
@@ -128,14 +128,6 @@ async function ensureCourseSlug(courseId: string, title: string | null | undefin
return updated.slug ?? uniqueSlug;
}
function buildCourseBaseUrl(organization: TOrganization) {
if (organization.customDomain && organization.isCustomDomainVerified) {
return `https://${organization.customDomain}`;
}
return getDashboardBaseUrl(organization.siteName ?? undefined);
}
async function resolveCourseUrl(organizationId: string, courseId: string, title: string) {
const organization = await getOrganizationById(organizationId);
if (!organization) {
@@ -143,7 +135,7 @@ async function resolveCourseUrl(organizationId: string, courseId: string, title:
}
const slug = await ensureCourseSlug(courseId, title);
return `${buildCourseBaseUrl(organization)}/course/${encodeURIComponent(slug)}`;
return `${getDashboardBaseUrl(organization)}/course/${encodeURIComponent(slug)}`;
}
async function resolveUnsplashBannerImage(courseTitle: string, query?: string) {
+41 -18
View File
@@ -26,7 +26,7 @@ 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 { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
import { getCourseTeachers } from '@cio/db/queries/course/people';
import { getProfileById } from '@cio/db/queries/auth';
import { buildEmailFromName } from '@cio/email';
@@ -37,6 +37,16 @@ import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance';
import { db } from '@cio/db/drizzle';
import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics';
/**
* Minimal org-url bundle threaded through invite/email paths so every URL
* we generate respects the org's verified custom domain.
*/
type OrgUrlInfo = {
siteName: string | null;
customDomain: string | null;
isCustomDomainVerified: boolean | null;
};
type InviteStatus = 'ACTIVE' | 'EXPIRED' | 'USED_UP' | 'REVOKED';
type InvitePreset = 'ONE_TIME_24H' | 'MULTI_USE_7D' | 'MULTI_USE_30D' | 'CUSTOM';
@@ -189,11 +199,12 @@ function getInviteStatus(invite: {
}
/**
* Builds the course enroll URL for invite links (emails, etc.).
* Uses DASHBOARD_URL when set (self-hosted), otherwise org subdomain in production.
* Builds the course enroll URL for invite links (emails, etc.). Respects the
* org's verified custom domain when present, otherwise falls back to the
* tenant subdomain.
*/
export function buildEnrollLink(courseSlug: string, token: string, orgSiteName?: string): string {
const base = getDashboardBaseUrl(orgSiteName);
export function buildEnrollLink(courseSlug: string, token: string, org?: OrgUrlInfo | null): string {
const base = getDashboardBaseUrl(org ?? undefined);
return `${base}/course/${encodeURIComponent(courseSlug)}/enroll?invite_token=${encodeURIComponent(token)}`;
}
@@ -308,7 +319,7 @@ async function createSingleInvite(
metadata: Record<string, unknown> | undefined,
targetEmail: string | null,
courseSlug: string,
orgSiteName: string | null
org: OrgUrlInfo
) {
const token = generateToken();
const tokenHash = hashToken(token);
@@ -338,7 +349,7 @@ async function createSingleInvite(
return {
...created,
inviteLink: buildEnrollLink(courseSlug, token, orgSiteName ?? undefined)
inviteLink: buildEnrollLink(courseSlug, token, org)
};
}
@@ -346,12 +357,12 @@ async function sendStudentJoinEmails(input: {
courseId: string;
courseName: string;
orgName: string;
orgSiteName: string | null;
org: OrgUrlInfo;
studentId: string;
studentEmail: string;
}) {
try {
const loginUrl = getDashboardBaseUrl(input.orgSiteName ?? undefined);
const loginUrl = getDashboardBaseUrl(input.org);
await enqueueTransactionalEmail('studentCourseWelcome', {
to: input.studentEmail,
fields: {
@@ -402,7 +413,7 @@ async function createEmailInviteAndSend(input: {
orgName: string;
courseName: string;
courseSlug: string;
orgSiteName: string | null;
org: OrgUrlInfo;
metadata?: Record<string, unknown>;
shouldSend: boolean;
}) {
@@ -421,7 +432,7 @@ async function createEmailInviteAndSend(input: {
},
input.recipientEmail,
input.courseSlug,
input.orgSiteName
input.org
);
if (!input.shouldSend) {
@@ -503,7 +514,11 @@ export async function createStudentInvite(courseId: string, createdByProfileId:
const courseTitle = course[0].title;
const courseSlug = course[0].slug ?? (await ensureCourseSlug(course[0].id, courseTitle));
const orgSiteName = courseOrgData.orgSiteName ?? null;
const orgUrlInfo: OrgUrlInfo = {
siteName: courseOrgData.orgSiteName ?? null,
customDomain: courseOrgData.orgCustomDomain ?? null,
isCustomDomainVerified: courseOrgData.orgIsCustomDomainVerified ?? null
};
if (recipients.valid.length === 0) {
const createdInvite = await createSingleInvite(
@@ -515,7 +530,7 @@ export async function createStudentInvite(courseId: string, createdByProfileId:
data.metadata,
null,
courseSlug,
orgSiteName
orgUrlInfo
);
return {
@@ -545,7 +560,7 @@ export async function createStudentInvite(courseId: string, createdByProfileId:
orgName,
courseName,
courseSlug,
orgSiteName,
org: orgUrlInfo,
metadata: data.metadata,
shouldSend: data.sendEmail
})
@@ -659,7 +674,7 @@ export async function enrollInCourse(
courseId,
courseName: title,
orgName: org.name,
orgSiteName: org.siteName,
org: { siteName: org.siteName, customDomain: org.customDomain, isCustomDomainVerified: org.isCustomDomainVerified },
studentId: user.id,
studentEmail: normalizedEmail
});
@@ -925,7 +940,9 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
courseId: course.id,
courseName: course.title,
orgName: organization.name,
orgSiteName: organization.siteName
orgSiteName: organization.siteName,
orgCustomDomain: organization.customDomain,
orgIsCustomDomainVerified: organization.isCustomDomainVerified
};
}
@@ -1010,7 +1027,9 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
courseId: course.id,
courseName: course.title,
orgName: organization.name,
orgSiteName: organization.siteName
orgSiteName: organization.siteName,
orgCustomDomain: organization.customDomain,
orgIsCustomDomainVerified: organization.isCustomDomainVerified
};
});
@@ -1021,7 +1040,11 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
courseId: result.courseId,
courseName: result.courseName,
orgName: result.orgName,
orgSiteName: result.orgSiteName,
org: {
siteName: result.orgSiteName,
customDomain: result.orgCustomDomain,
isCustomDomainVerified: result.orgIsCustomDomainVerified
},
studentId: user.id,
studentEmail: normalizedEmail
});
+11 -3
View File
@@ -11,7 +11,7 @@ import {
import { ROLE } from '@cio/utils/constants';
import type { TAddCourseMembers } from '@cio/utils/validation/course/people';
import type { TGroupmember } from '@cio/db/types';
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
import { getCourseWithOrgData } from '@cio/db/queries/course';
import { getProfileById } from '@cio/db/queries/auth';
import { buildEmailFromName } from '@cio/email';
@@ -68,7 +68,11 @@ export async function addMember(
if (courseOrgData) {
const courseName = courseOrgData.courseTitle || '';
const orgName = courseOrgData.orgName || 'ClassroomIO';
const loginUrl = getDashboardBaseUrl(courseOrgData.orgSiteName ?? undefined);
const loginUrl = getDashboardBaseUrl({
siteName: courseOrgData.orgSiteName,
customDomain: courseOrgData.orgCustomDomain,
isCustomDomainVerified: courseOrgData.orgIsCustomDomainVerified
});
// Get student profile data
let studentEmail: string | null = null;
@@ -190,7 +194,11 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) {
}
// Send welcome emails for teachers (TUTOR role = 2, ADMIN role = 1)
const baseUrl = getDashboardBaseUrl(orgSiteName);
const baseUrl = getDashboardBaseUrl({
siteName: orgSiteName,
customDomain: courseOrgData.orgCustomDomain,
isCustomDomainVerified: courseOrgData.orgIsCustomDomainVerified
});
const inviteLink = `${baseUrl}/org/${orgSiteName}/courses`;
const teacherEmailPromises: Promise<unknown>[] = [];
+53 -2
View File
@@ -3,10 +3,11 @@ import {
generateTranscriptVttPresignedUrl,
generateVideoDownloadPresignedUrls,
TRANSCRIPT_VTT_PRESIGN_SECONDS
} from '@api/utils/s3';
} from '@cio/core/utils/s3';
import { fetchQuestionsAndOptions, transformQuestions } from '@api/services/exercise/utils';
import { getMediaTranscriptByAsset } from '@cio/db/queries';
import { getAssetsByIds } from '@cio/db/queries/assets';
import { mintHlsToken } from '@cio/core/services/assets/assets';
import {
getCourseRowBySlug,
getPublicCourseItem as getPublicCourseItemQuery,
@@ -72,6 +73,43 @@ export interface PublicExerciseQuestion {
* For exercises, questions + options are returned with answer keys included
* because PUBLIC courses grade entirely on the client.
*/
/**
* Mint a `cio_hls` cookie for an anonymous learner viewing a public
* lesson. The asset id is **derived from the public course tree**, not
* accepted from the caller — so a learner who only knows a random asset
* UUID cannot mint a cookie. They have to know a public course slug +
* lesson slug whose video actually exposes the asset.
*/
export async function issuePublicHlsCookieService(courseSlug: string, itemSlug: string) {
try {
const item = await getPublicCourseItemQuery(courseSlug, itemSlug);
if (!item || item.kind !== 'lesson') {
throw new AppError('Public lesson not found', ErrorCodes.NOT_FOUND, 404);
}
const video = item.video;
if (video?.type !== 'upload' || !video.assetId) {
throw new AppError('Lesson has no playable HLS asset', ErrorCodes.VALIDATION_ERROR, 400);
}
const assetId = video.assetId;
const assets = await getAssetsByIds([assetId], item.courseOrganizationId ?? undefined);
const asset = assets[0];
if (!asset?.hlsManifestKey) {
throw new AppError('Lesson has no playable HLS asset', ErrorCodes.VALIDATION_ERROR, 400);
}
return await mintHlsToken(assetId);
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to issue HLS cookie',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function getPublicCourseItemService(
courseSlug: string,
itemSlug: string
@@ -87,17 +125,30 @@ export async function getPublicCourseItemService(
if (video?.type === 'upload') {
let storageKey: string | undefined;
let hlsManifestKey: string | null = null;
if (video.assetId) {
const assets = await getAssetsByIds([video.assetId], courseOrganizationId ?? undefined);
storageKey = assets[0]?.storageKey ?? undefined;
hlsManifestKey = assets[0]?.hlsManifestKey ?? null;
}
if (!storageKey && video.key) {
storageKey = video.key;
}
if (storageKey) {
// HLS assets: serve the manifest URL via the api route (auth'd by
// the cio_hls cookie that the public-cookie endpoint mints when the
// learner loads the lesson). Don't presign storageKey — for HLS
// it's null anyway.
if (hlsManifestKey) {
const { key: _omitKey, ...rest } = video;
video = {
...rest,
link: `/hls/${hlsManifestKey}`.replace(/\/{2,}/g, '/'),
hls: true
};
} else if (storageKey) {
const urls = await generateVideoDownloadPresignedUrls([storageKey]);
const signedLink = urls[storageKey];
+2 -2
View File
@@ -1,5 +1,5 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { env } from '@api/config/env';
import { env } from '@cio/core/config/env';
import {
getCourseStats,
getDashOrgStats,
@@ -9,7 +9,7 @@ import {
getTotalCertificatesIssued
} from '@cio/db/queries/dash';
import { dashLoginActivityKey } from '@api/utils/redis/key-generators';
import { logRedisUnavailableOnce, redis } from '@api/utils/redis/redis';
import { logRedisUnavailableOnce, redis } from '@cio/core/utils/redis/redis';
import { OrganisationAnalytics } from '@api/types';
import { getOrgIdBySiteName } from '@cio/db/queries';
+1 -1
View File
@@ -1 +1 @@
export * from './exercise';
export * from '@cio/core/services/exercise/exercise';
@@ -1,6 +1,6 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { getStorageConfig } from '@api/config/storage';
import { generateVideoDownloadPresignedUrls, generateVideoUploadPresignedUrl } from '@api/utils/s3';
import { getStorageConfig } from '@cio/core/config/storage';
import { generateVideoDownloadPresignedUrls, generateVideoUploadPresignedUrl } from '@cio/core/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';
+1 -1
View File
@@ -9,4 +9,4 @@ export * from './newsfeed';
export * from './onboarding';
export * from './submission';
export * from './dash';
export * from './course/course';
export * from '@cio/core/services/course/course';
+1 -1
View File
@@ -2,7 +2,7 @@ import { enqueueEmailSend, isRedisConfigured } from '@cio/jobs';
import { EmailRegistry, type EmailId, type EmailSchemaFor } from '@cio/email';
import * as z from 'zod';
import { logRedisUnavailableOnce } from '@api/utils/redis/redis';
import { logRedisUnavailableOnce } from '@cio/core/utils/redis/redis';
type Recipient = string | string[];
+7 -3
View File
@@ -18,7 +18,7 @@ import {
isRedisConfigured
} from '@cio/jobs';
import { logRedisUnavailableOnce } from '@api/utils/redis/redis';
import { logRedisUnavailableOnce } from '@cio/core/utils/redis/redis';
import type { TMediaJob } from '@db/types';
export interface StartTranscriptionOnlyMediaJobInput {
@@ -41,7 +41,11 @@ export async function startTranscriptionOnlyMediaJob(input: StartTranscriptionOn
throw new AppError('Asset not found', ErrorCodes.NOT_FOUND, 404);
}
if (asset.kind !== 'video' || asset.provider !== 'upload' || !asset.storageKey) {
// HLS uploads carry their pre-extracted audio in `hls_audio_key` and leave
// `storage_key` null. Raw MP4 uploads keep using `storage_key`.
const transcriptionSourceKey = asset.hlsAudioKey ?? asset.storageKey;
if (asset.kind !== 'video' || asset.provider !== 'upload' || !transcriptionSourceKey) {
throw new AppError('Asset cannot be transcribed', ErrorCodes.ASSET_NOT_TRANSCRIBABLE, 400);
}
@@ -71,7 +75,7 @@ export async function startTranscriptionOnlyMediaJob(input: StartTranscriptionOn
const enqueueResult = await enqueueTranscriptionOnly({
mediaJobId: job.id,
assetId: input.assetId,
storageKey: asset.storageKey,
storageKey: transcriptionSourceKey,
actorContext: {
userId: input.triggeredByProfileId,
organizationId: input.organizationId
+1 -1
View File
@@ -1 +1 @@
export * from './lesson';
export * from '@cio/core/services/lesson/lesson';
+1 -1
View File
@@ -1,4 +1,4 @@
import { env } from '@api/config/env';
import { env } from '@cio/core/config/env';
import { LICENSE_FEATURE, type LicenseFeatureId } from '@cio/utils/license';
export type LicenseStatus = {
+3 -3
View File
@@ -2,9 +2,9 @@ import { AppError, ErrorCodes } from '@api/utils/errors';
import { MAX_IMAGE_SIZE } from '@api/constants/upload';
import { ALLOWED_IMAGE_TYPES } from '@cio/utils/validation';
import { getStorageConfig } from '@api/config/storage';
import { generateFileKey } from '@api/utils/upload';
import { uploadToS3 } from '@api/utils/s3';
import { getStorageConfig } from '@cio/core/config/storage';
import { generateFileKey } from '@cio/core/utils/upload';
import { uploadToS3 } from '@cio/core/utils/s3';
/**
* Uploads an image file to object storage and returns the public URL
+5 -4
View File
@@ -1,5 +1,6 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { sanitizeHtml } from '@api/utils/sanitize-html';
import { sanitizeHtml } from '@cio/core/utils/sanitize-html';
import { TENANT_ROOT_DOMAIN } from '@cio/utils/constants/domains';
import type { TCourseNewsfeed, TNewCourseNewsfeed, TNewCourseNewsfeedComment } from '@cio/db/types';
import type { TNewsfeedCreate, TNewsfeedUpdate } from '@cio/utils/validation/newsfeed';
import {
@@ -17,7 +18,7 @@ import {
updateNewsfeedComment
} from '@cio/db/queries/newsfeed';
import { env } from '@api/config/env';
import { env } from '@cio/core/config/env';
import { buildEmailFromName } from '@cio/email';
import { enqueueTransactionalEmail } from '@api/services/jobs';
@@ -358,7 +359,7 @@ async function sendNewsfeedPostEmail(feedId: string, authorId: string) {
const orgName = feedData.organization?.name || 'ClassroomIO';
const orgSiteName = feedData.organization?.siteName || 'app';
const postLink = `https://${orgSiteName}.classroomio.com/courses/${feedData.courseId}?feedId=${feedData.feedId}`;
const postLink = `https://${orgSiteName}.${TENANT_ROOT_DOMAIN}/courses/${feedData.courseId}?feedId=${feedData.feedId}`;
const recipients = feedData.courseMembers
.map((member) => member.email)
@@ -402,7 +403,7 @@ async function sendNewsfeedCommentEmail(feedId: string, commentContent: string)
const orgName = feedData.organization?.name || 'ClassroomIO';
const orgSiteName = feedData.organization?.siteName || 'app';
const postLink = `https://${orgSiteName}.classroomio.com/courses/${feedData.courseId}?feedId=${feedData.feedId}`;
const postLink = `https://${orgSiteName}.${TENANT_ROOT_DOMAIN}/courses/${feedData.courseId}?feedId=${feedData.feedId}`;
await enqueueTransactionalEmail('newsfeedComment', {
to: feedData.author.email,
+1 -1
View File
@@ -10,7 +10,7 @@ import {
} from '@cio/db/queries';
import { getProfileById, updateProfile } from '@cio/db/queries/auth';
import { env } from '@api/config/env';
import { env } from '@cio/core/config/env';
import { ROLE } from '@cio/utils/constants';
import { PLAN } from '@cio/utils/plans';
import { db } from '@cio/db/drizzle';
+1 -1
View File
@@ -1,6 +1,6 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { env } from '@api/config/env';
import { env } from '@cio/core/config/env';
export type DomainSetupStatus =
| 'reconnect_required'
+23 -13
View File
@@ -49,6 +49,7 @@ import { inviteTeamMembers as inviteTeamMembersSecure } from './organization/inv
import { trustCustomDomainHostname, untrustCustomDomainHostname } from '@cio/db/utils';
const PUBLIC_ORG_LANDING_PAGE_COURSE_LIMIT = 4;
const ORG_COURSES_PAGE_SIZE = 6;
/**
* Creates a new organization with the current user as owner
@@ -252,7 +253,11 @@ export async function removeAudienceMember(orgId: string, memberId: number, remo
* @param siteName - The organization siteName
* @returns Array of published courses with lesson counts
*/
export async function getPublicCourses(siteName: string, tagSlugs?: string[]) {
export async function getPublicCourses(
siteName: string,
tagSlugs?: string[],
pagination?: { page?: number; limit?: number }
) {
try {
const orgResult = await getOrgIdBySiteName(siteName);
const org = orgResult[0];
@@ -261,23 +266,28 @@ export async function getPublicCourses(siteName: string, tagSlugs?: string[]) {
throw new AppError('Organization not found', ErrorCodes.ORG_NOT_FOUND, 404);
}
const isPaginated = pagination !== undefined;
const limit = isPaginated ? (pagination.limit ?? ORG_COURSES_PAGE_SIZE) : PUBLIC_ORG_LANDING_PAGE_COURSE_LIMIT;
const page = isPaginated ? (pagination.page ?? 1) : 1;
const offset = (page - 1) * limit;
let filteredCourseIds: string[] | undefined = undefined;
if (tagSlugs && tagSlugs.length > 0) {
filteredCourseIds = await getCourseIdsByTagSlugs(org.id, tagSlugs);
if (filteredCourseIds.length === 0) {
return {
courses: [],
hasMoreCourses: false
hasMoreCourses: false,
total: 0,
page,
limit,
totalPages: 0
};
}
}
const totalCourses = await countPublishedCoursesBySiteName(siteName, filteredCourseIds);
const courses = await getPublishedCoursesBySiteName(
siteName,
filteredCourseIds,
PUBLIC_ORG_LANDING_PAGE_COURSE_LIMIT
);
const courses = await getPublishedCoursesBySiteName(siteName, filteredCourseIds, limit, offset);
const tagsByCourseId = await getCourseTagsByCourseIdsForOrganization(
org.id,
courses.map((course) => course.id)
@@ -288,7 +298,11 @@ export async function getPublicCourses(siteName: string, tagSlugs?: string[]) {
...course,
tags: tagsByCourseId[course.id] ?? []
})),
hasMoreCourses: totalCourses > courses.length
hasMoreCourses: offset + courses.length < totalCourses,
total: totalCourses,
page,
limit,
totalPages: Math.ceil(totalCourses / limit)
};
} catch (error) {
if (error instanceof AppError) throw error;
@@ -555,11 +569,7 @@ async function resolveOrgPlanTriggeredBy(params: {
}
}
throw new AppError(
'Could not resolve organization member for plan',
ErrorCodes.ORG_PLAN_CREATE_FAILED,
400
);
throw new AppError('Could not resolve organization member for plan', ErrorCodes.ORG_PLAN_CREATE_FAILED, 400);
}
/**
@@ -27,7 +27,7 @@ import { updateOrganizationAudienceMember } from '@cio/db/queries/organization';
import { ROLE } from '@cio/utils/constants';
import crypto from 'node:crypto';
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
import { getProfilesByEmails } from '@cio/db/queries/auth';
import { ensureComplianceEnrollmentRecordsForProfiles } from '../course/compliance';
@@ -195,7 +195,7 @@ async function enrollAudienceStudentProfilesInCourses(
}
let emailsSent = 0;
const loginUrl = getDashboardBaseUrl(organization.siteName ?? undefined);
const loginUrl = getDashboardBaseUrl(organization);
if (shouldSendEmail && toInsert.length > 0) {
const emailPromises = toInsert
@@ -254,7 +254,7 @@ async function enrollAudienceStudentProfilesInPrograms(
}
const programNameById = new Map(programs.map((program) => [program.id, program.name || 'Program']));
const loginUrl = getDashboardBaseUrl(organization.siteName ?? undefined);
const loginUrl = getDashboardBaseUrl(organization);
const validProgramIds = programs.map((program) => program.id);
const validProfiles = uniqueProfileIds.filter((profileId) => validProfileIds.has(profileId));
const pairs = validProfiles.flatMap((profileId) => validProgramIds.map((programId) => ({ programId, profileId })));
@@ -354,7 +354,7 @@ async function createStudentOrgInvitesAndSendEmails(input: {
});
const invites = await createOrganizationInvites(inviteInputs.map((i) => i.row));
const inviteByEmail = new Map(invites.map((inv) => [inv.email.toLowerCase(), inv]));
const inviteByEmail = new Map(invites.map((inv) => [(inv.email ?? '').toLowerCase(), inv]));
const tokenByEmail = new Map(inviteInputs.map((i) => [i.email.toLowerCase(), i.token]));
await createOrganizationInviteAudits(
+154 -3
View File
@@ -6,14 +6,18 @@ import {
createOrganizationInviteAudit,
createOrganizationMember,
createOrganizationMembers,
createLinkInvite,
getActivePendingOrgInviteForEmail,
getOrganizationById,
getOrganizationInviteByTokenHash,
getOrgLinkInvite,
getOrgLinkInviteWithOrg,
revokeActiveOrganizationInvitesByEmails,
selectOrganizationInviteWithOrgByInviteId,
selectOrganizationInviteWithOrgByTokenHash,
selectOrganizationMemberByOrgAndNormalizedEmail,
selectOrganizationMemberByOrgAndProfile,
setLinkInviteRevoked,
updateOrganizationMemberById
} from '@cio/db/queries/organization';
import { getCourseGroupIds } from '@cio/db/queries/course';
@@ -24,7 +28,7 @@ import { ROLE } from '@cio/utils/constants';
import type { TNewOrganizationInviteAudit } from '@db/types';
import crypto from 'node:crypto';
import { db, type DbOrTxClient } from '@cio/db/drizzle';
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
import { parseCourseIdsFromInviteMetadata, parseProgramIdsFromInviteMetadata } from '@api/utils/org';
import { markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile';
import { enqueueTransactionalEmail } from '@api/services/jobs';
@@ -338,7 +342,8 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
throw new AppError('This invite has expired', ErrorCodes.VALIDATION_ERROR, 400);
}
if (row.invite.email.toLowerCase().trim() !== normalizedEmail) {
const inviteEmail = row.invite.email ?? '';
if (inviteEmail.toLowerCase().trim() !== normalizedEmail) {
throw new AppError('This invite is for a different email address', ErrorCodes.UNAUTHORIZED, 403);
}
@@ -447,6 +452,151 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
};
}
// ─── Link Invite ────────────────────────────────────────────────────────────
const LINK_INVITE_FAR_FUTURE_MS = 100 * 365 * 24 * 60 * 60 * 1000;
/**
* Returns the current org link invite if one already exists, otherwise creates one.
* One link invite per org — subsequent calls return the same record.
*/
export async function getOrCreateLinkInvite(orgId: string, roleId: number, profileId: string) {
if (roleId !== ROLE.ADMIN && roleId !== ROLE.TUTOR) {
throw new AppError('Invalid organization role for link invite', ErrorCodes.VALIDATION_ERROR, 400, 'roleId');
}
const existing = await getOrgLinkInvite(orgId);
if (existing) {
const token = (existing.metadata as Record<string, unknown>)?.token as string | undefined;
return { id: existing.id, token: token ?? '', roleId: existing.roleId, isRevoked: existing.isRevoked };
}
const token = generateToken();
const tokenHash = hashToken(token);
const expiresAt = new Date(Date.now() + LINK_INVITE_FAR_FUTURE_MS).toISOString();
const invite = await createLinkInvite({
organizationId: orgId,
roleId,
tokenHash,
createdByProfileId: profileId,
expiresAt,
metadata: { token, source: 'ORG_SETTINGS_LINK_INVITE' }
});
await recordOrganizationInviteAudit(invite.id, orgId, 'CREATED', {
actorProfileId: profileId,
metadata: { roleId, type: 'LINK' }
});
return { id: invite.id, token, roleId: invite.roleId, isRevoked: invite.isRevoked };
}
/**
* Returns current link invite for the org (for GET endpoint — returns null if none created yet).
*/
export async function fetchOrgLinkInvite(orgId: string) {
const row = await getOrgLinkInvite(orgId);
if (!row) return null;
const token = (row.metadata as Record<string, unknown>)?.token as string | undefined;
return { id: row.id, token: token ?? '', roleId: row.roleId, isRevoked: row.isRevoked };
}
/**
* Enables or disables the org link invite (toggle isRevoked).
*/
export async function toggleOrgLinkInvite(orgId: string, isRevoked: boolean, profileId: string) {
const updated = await setLinkInviteRevoked(orgId, isRevoked, profileId);
if (!updated) {
throw new AppError('Link invite not found', ErrorCodes.NOT_FOUND, 404);
}
const token = (updated.metadata as Record<string, unknown>)?.token as string | undefined;
return { id: updated.id, token: token ?? '', roleId: updated.roleId, isRevoked: updated.isRevoked };
}
/**
* Server-only preview for a link invite (API-key protected at route layer).
*/
export async function previewLinkInvite(token: string, context: TInviteRequestContext = {}) {
const tokenHash = hashToken(token);
const data = await getOrgLinkInviteWithOrg(db, tokenHash);
if (!data) {
throw new AppError('Invalid invite link', ErrorCodes.NOT_FOUND, 404);
}
await recordOrganizationInviteAudit(data.invite.id, data.invite.organizationId, 'PREVIEWED', {
ipAddress: context.ipAddress,
userAgent: context.userAgent
});
return {
invite: {
id: data.invite.id,
roleId: data.invite.roleId,
roleLabel: getRoleLabel(data.invite.roleId),
isRevoked: data.invite.isRevoked
},
organization: data.organization
};
}
/**
* Accepts a link invite — adds the authenticated user to the org.
* The invite is NOT consumed (reusable). Anyone with the link can join.
*/
export async function acceptLinkInvite(token: string, user: TAuthUser, context: TInviteRequestContext = {}) {
if (!user.id || !user.email) {
throw new AppError('Authenticated user email is required', ErrorCodes.UNAUTHORIZED, 401);
}
const normalizedEmail = user.email.toLowerCase().trim();
const tokenHash = hashToken(token);
const result = await db.transaction(async (tx) => {
const row = await getOrgLinkInviteWithOrg(tx, tokenHash);
if (!row) {
throw new AppError('Invalid invite link', ErrorCodes.NOT_FOUND, 404);
}
if (row.invite.isRevoked) {
throw new AppError('This invite link has been disabled', ErrorCodes.UNAUTHORIZED, 403);
}
await syncOrgMemberForOrgInvite(tx, {
organizationId: row.invite.organizationId,
roleId: row.invite.roleId,
normalizedEmail,
userId: user.id
});
await markUserAndProfileEmailVerified(user.id, tx);
return { organization: row.organization, roleId: row.invite.roleId, inviteId: row.invite.id };
});
await recordOrganizationInviteAudit(result.inviteId, result.organization.id, 'ACCEPTED', {
actorProfileId: user.id,
targetEmail: normalizedEmail,
ipAddress: context.ipAddress,
userAgent: context.userAgent,
metadata: { source: 'LINK_INVITE' }
});
const siteName = result.organization.siteName || '';
const redirectTo = result.roleId === ROLE.STUDENT ? '/lms' : siteName ? `/org/${siteName}` : '/org';
return { organizationId: result.organization.id, roleId: result.roleId, redirectTo };
}
// ─── End Link Invite ─────────────────────────────────────────────────────────
/**
* Returns the pending org invite for the currently logged-in user, if one exists.
* Used by the LMS dashboard to prompt the student to accept on first load.
@@ -503,7 +653,8 @@ export async function acceptOrganizationInviteById(
throw new AppError('This invite has expired', ErrorCodes.VALIDATION_ERROR, 400);
}
if (row.invite.email.toLowerCase().trim() !== normalizedEmail) {
const inviteEmailById = row.invite.email ?? '';
if (inviteEmailById.toLowerCase().trim() !== normalizedEmail) {
throw new AppError('This invite is for a different email address', ErrorCodes.UNAUTHORIZED, 403);
}
+6 -2
View File
@@ -1,6 +1,6 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { enqueueTransactionalEmail } from '@api/services/jobs';
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
import {
countAssignmentsByStatus,
createProgramGoal,
@@ -408,7 +408,11 @@ export async function runProgramGoalReminderScan(): Promise<{
if (!matchesReminder) continue;
const loginUrl = getDashboardBaseUrl(row.organizationSiteName ?? undefined);
const loginUrl = getDashboardBaseUrl({
siteName: row.organizationSiteName,
customDomain: row.organizationCustomDomain,
isCustomDomainVerified: row.organizationIsCustomDomainVerified
});
const dayKey = now.toISOString().slice(0, 10);
try {
@@ -41,8 +41,8 @@ import { getGroupMemberIdByCourseAndProfile, isCourseTeamMemberOrOrgAdmin } from
import { QUESTION_TYPE_ID_TO_KEY } from '@cio/question-types';
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { generateDocumentDownloadPresignedUrls, generateVideoDownloadPresignedUrls } from '@api/utils/s3';
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
import { generateDocumentDownloadPresignedUrls, generateVideoDownloadPresignedUrls } from '@cio/core/utils/s3';
import { syncComplianceProgressFromSubmission } from '@api/services/course/compliance';
type SubmissionGradingState = 'queued' | 'processing' | 'awaiting_manual' | 'completed' | 'failed';
+8 -2
View File
@@ -114,6 +114,10 @@ export async function getPublicOrganizationTagGroups(siteName: string) {
for (const group of groups) {
for (const tag of group.tags) {
if (tag.courseCount <= 0) {
continue;
}
const groupId = tag.groupId || group.id;
const existing = tagsByGroupId.get(groupId) ?? [];
@@ -124,10 +128,12 @@ export async function getPublicOrganizationTagGroups(siteName: string) {
}
}
return groups.map((group) => ({
return groups
.map((group) => ({
...group,
tags: tagsByGroupId.get(group.id) ?? []
}));
}))
.filter((group) => group.tags.length > 0);
} catch (error) {
if (error instanceof AppError) {
throw error;
+1 -1
View File
@@ -12,7 +12,7 @@ import {
deleteCourse as deleteCourseService,
getCourse,
updateCourse as updateCourseService
} from '@api/services/course/course';
} from '@cio/core/services/course/course';
import {
createCourseImportDraftService,
getCourseImportStructureService,
+1 -1
View File
@@ -13,7 +13,7 @@ import {
} from '@cio/utils/validation/widget';
import type { TWidget } from '@db/types';
import * as csstree from 'css-tree';
import { env } from '@api/config/env';
import { env } from '@cio/core/config/env';
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
const DEFAULT_EMBED_CDN_BASE_URL = 'https://assets.cdn.clsrio.com';
+6 -2
View File
@@ -1,4 +1,4 @@
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
import {
getCourseProgress as getCourseProgressQuery,
type TCourseCertificationRow
@@ -67,7 +67,11 @@ export function scheduleCertificationCompletionWork(params: {
return;
}
const base = getDashboardBaseUrl(courseRow.orgSiteName ?? undefined);
const base = getDashboardBaseUrl({
siteName: courseRow.orgSiteName,
customDomain: courseRow.orgCustomDomain,
isCustomDomainVerified: courseRow.orgIsCustomDomainVerified
});
const certificateUrl = `${base}/courses/${courseId}/certificates`;
await enqueueTransactionalEmail('studentCourseCompletion', {
+8 -19
View File
@@ -1,24 +1,13 @@
import type { ContentfulStatusCode } from 'hono/utils/http-status';
import type { Context } from 'hono';
import type { ContentfulStatusCode } from 'hono/utils/http-status';
import { ErrorCodes, type ErrorCode } from '@cio/utils/constants';
import { AppError, ErrorCodes, type ErrorCode } from '@cio/utils/errors';
export { ErrorCodes, type ErrorCode };
/**
* Standard application error with code for easy identification
*/
export class AppError extends Error {
constructor(
error: string | Error,
public code: string,
public statusCode: ContentfulStatusCode = 500,
public field?: string
) {
super(error instanceof Error ? error.message : error);
this.name = 'AppError';
}
}
// AppError moved to `@cio/utils/errors` so it can be thrown from
// non-api workspace packages (notably `@cio/core`). Re-exported
// here to keep the long-standing `@api/utils/errors` import path working
// for the rest of the api codebase.
export { AppError, ErrorCodes, type ErrorCode };
/**
* Standard error response format
@@ -68,7 +57,7 @@ export const handleError = (
code: responseCode,
field: isServerError ? undefined : error.field
},
error.statusCode
error.statusCode as ContentfulStatusCode
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { Hono } from 'hono';
import { Scalar } from '@scalar/hono-api-reference';
import { env } from '@api/config/env';
import { env } from '@cio/core/config/env';
import { AuthSession } from '@api/types/auth';
export function configureOpenAPI(app: Hono<AuthSession>) {
+1 -1
View File
@@ -5,7 +5,7 @@ import {
RATE_LIMIT_KEY_PREFIX
} from '@api/constants/rate-limiter';
import { redis, type RedisClient } from './redis';
import { redis, type RedisClient } from '@cio/core/utils/redis/redis';
export class RedisRateLimiter {
private redis: RedisClient;
+1 -1
View File
@@ -1,4 +1,4 @@
import { logRedisUnavailableOnce, redis } from './redis';
import { logRedisUnavailableOnce, redis } from '@cio/core/utils/redis/redis';
const KEY = (token: string) => `oauth-handoff:${token}`;
const TTL_SECONDS = 120; // OAuth callbacks usually finish in < 2s; 2 minutes gives plenty of slack.
+1
View File
@@ -35,6 +35,7 @@
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tsconfig.tsbuildinfo"],
"references": [
{ "path": "../../packages/core" },
{ "path": "../../packages/db" },
{ "path": "../../packages/utils" },
{ "path": "../../packages/email" },
+2
View File
@@ -56,6 +56,7 @@
"d3-sankey": "^0.12.3",
"dayjs": "^1.11.10",
"encoding": "^0.1.13",
"hls.js": "^1.6.16",
"hotkeys-js": "^3.11.2",
"html-to-image": "^1.11.11",
"is-valid-domain": "^0.1.6",
@@ -65,6 +66,7 @@
"ky": "^1.10.0",
"lodash": "^4.17.21",
"marked": "^7.0.5",
"mediabunny": "^1.45.2",
"openai-edge": "^1.2.2",
"papaparse": "^5.4.1",
"pluralize": "^8.0.0",
-52
View File
@@ -10,61 +10,9 @@
<link rel="icon" type="image/png" sizes="32x32" href="/logo-32.png" />
%sveltekit.head%
<style id="app-loading-styles">
#app-loading-indicator {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #fff;
z-index: 99999;
}
#app-loading-indicator .spinner {
width: 3.5rem;
height: 3.5rem;
color: #1d4ed8;
animation: app-loading-spin 1s linear infinite;
}
@keyframes app-loading-spin {
to {
transform: rotate(360deg);
}
}
#app-loading-indicator p {
margin-top: 16px;
color: #6b7280;
font-family: system-ui, sans-serif;
}
</style>
</head>
<body data-sveltekit-preload-data="hover" class="">
<div id="app-loading-indicator">
<svg
class="spinner"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
role="status"
aria-label="Loading"
>
<path d="M12 2v4" />
<path d="m16.2 7.8 2.9-2.9" />
<path d="M18 12h4" />
<path d="m16.2 16.2 2.9 2.9" />
<path d="M12 18v4" />
<path d="m4.9 19.1 2.9-2.9" />
<path d="M2 12h4" />
<path d="m4.9 4.9 2.9 2.9" />
</svg>
<p>Loading...</p>
</div>
<div style="display: contents" class="theme-container">%sveltekit.body%</div>
</body>
</html>
+2
View File
@@ -41,6 +41,8 @@ export const handle: Handle = async (args) => {
return proxyRequestToApi(event.request);
}
const cookieString = event.cookies.getAll().map((c) => `${c.name}=${c.value}`);
console.log('event.cookies', cookieString);
const sessionData = await getSessionData(event.cookies);
if (sessionData) {
@@ -5,7 +5,8 @@ import type {
CreateAccountWorkspaceRequest,
DeleteAccountWorkspaceRequest,
GetAccountUsageRequest,
ListAccountWorkspacesRequest
ListAccountWorkspacesRequest,
ViewAsStudentTokenRequest
} from '../utils/types';
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import { ZCreateWorkspace, type TCreateWorkspace } from '@cio/utils/validation/account';
@@ -29,6 +30,20 @@ class AccountApi extends BaseApiWithErrors {
});
}
/**
* Mint a short-lived login-link token for the current user. Used by the
* "View as student" handoff to auto-authenticate the teacher on the org domain.
* Returns the token, or undefined on failure.
*/
async createViewAsStudentToken(): Promise<string | undefined> {
const result = await this.execute<ViewAsStudentTokenRequest>({
requestFn: () => classroomio.account['view-as-student-token'].$post(),
logContext: 'creating view-as-student token'
});
return result?.data.token;
}
async loadUsage() {
return this.execute<GetAccountUsageRequest>({
requestFn: () => classroomio.account.usage.$get(),
@@ -4,6 +4,7 @@ export type ListAccountWorkspacesRequest = typeof classroomio.account.workspaces
export type CreateAccountWorkspaceRequest = typeof classroomio.account.workspaces.$post;
export type DeleteAccountWorkspaceRequest = (typeof classroomio.account.workspaces)[':workspaceId']['$delete'];
export type GetAccountUsageRequest = typeof classroomio.account.usage.$get;
export type ViewAsStudentTokenRequest = (typeof classroomio.account)['view-as-student-token']['$post'];
type ListSuccess = Extract<InferResponseType<ListAccountWorkspacesRequest>, { success: true }>;
type UsageSuccess = Extract<InferResponseType<GetAccountUsageRequest>, { success: true }>;
@@ -0,0 +1,91 @@
# Chat-mode vs Run-mode — where the split lives and why it's fragile
The AI assistant has two execution modes that share most of their plumbing. This doc enumerates exactly where each mode is decided, surfaced, and routed, and the three places the split is currently load-bearing on heuristics rather than enforcement.
## Mode definitions
- **Chat mode** — interactive, request/response. The user types, the API streams an assistant turn, and tool calls execute _inline inside that turn_. No `agent_run` row. Lives in the foreground HTTP process. Bounded by AI SDK `stopWhen` (currently `stepCountIs(...)`) and the user's session.
- **Run mode** — durable background execution. The user (or the agent on the user's behalf) creates an `agent_run` row; a BullMQ job is enqueued; the worker process drives the same tools to completion across many rounds. Pollable. Cancellable. Resumable.
Both modes call the **same tool implementations** (`packages/core/src/services/agent/chat-tools.ts`) against the **same schemas** (`agent-tool-schemas.ts`). The only difference is _who is driving the loop and where_.
## Where the split is decided
### 1. Trigger — only one path creates a run
- `apps/dashboard/src/lib/features/ai-assistant/ai-course-chat.svelte:617` — `handleImplementPlan(editedPlan)`. Called when the user clicks **Implement Plan** in the plan-view card.
- `apps/dashboard/src/lib/features/ai-assistant/api/ai-assistant.svelte.ts:291` — `aiAssistantApi.createRun(...)` posts to the API.
- `apps/api/src/routes/agent/runs.ts:41` — `POST /agent/runs` inserts the row and enqueues a BullMQ job.
Everything else — every other user message, every tool call the model makes during a chat turn — is **chat mode by default**. There is no second trigger.
### 2. Worker — what actually executes a run
- `apps/jobs/src/workers/agent-course-generation.ts` — the BullMQ worker that pulls run jobs.
- `packages/core/src/services/agent/course-generation-runner.ts` — `runAgentCourseGenerationJob(...)`, the loop the worker invokes. Streams tool calls, persists steps, respects cancel/pause.
### 3. Chat — the other endpoint
- `apps/api/src/routes/agent/agent.ts` — the `/agent/chat` route. Streams an assistant turn via the AI SDK with the same tool set bound. This is where every chat-mode tool call actually fires.
### 4. UI mode switching
- `ai-course-chat.svelte:97-103` — the status sets that classify a run:
- `RUN_MODE_STATUSES` — `queued|running|waiting_for_input|paused|failed|canceled`. Any of these keeps the panel in "running" mode.
- `RUN_RESUMABLE_STATUSES` — `paused|waiting_for_input|canceled`. Controls the Resume button.
- `INJECTABLE_RUN_STATUSES` — `completed`. The only status that auto-injects a synthetic summary message.
- `ai-course-chat.svelte:898-903` — `panelMode` derivation. Reads `aiAssistantApi.currentRun.status` and flips the bottom of the chat between `RunMonitorPane` and `ChatInput`.
- `ai-course-chat.svelte:1320-1346` — render switch.
### 5. Prompt — the _de facto_ routing logic
- `packages/ai-assistant/src/prompt/teacher.ts:55-104` — Plan Mode vs Agent Mode, plus "Driving an approved plan to completion" and "Re-engaging a course where an approved plan exists".
This file is where the model is _told_ to use `generate_course_plan` for big work and inline tools for small edits. **It is the only thing keeping the model from doing everything inline in chat.** No code rejects a chat-mode call to a "heavy" tool.
## Why the split is fragile
### 1. Mode routing is LLM-decided, not enforced
The same tool set is bound in both modes. Whether `update_lesson_content` for ten lessons runs inline (chat) or via a plan→run is decided entirely by how the model interprets the system prompt. Drift in the prompt, a new model with different defaults, or a user prompt that subtly bypasses the plan flow — any of these route heavy work into chat mode where there's no retry, no resume, no progress card, and no cancel button.
**Observed:** a transcript where the agent updated three lessons inline across one streamed turn. None of it ran through the durable runner. The user only realized when they noticed there was no run history entry.
### 2. Run can stick at `queued`
Run creation enqueues to BullMQ. If the worker can't pick the job up (worker crashed, Redis blip, module-resolution boot failure, schema mismatch on payload), the run sits at `queued` indefinitely. The frontend polls run status but has no "worker hasn't moved past queued in N seconds" guardrail. The user sees a stalled progress card with no error.
**Observed:** `ERR_MODULE_NOT_FOUND` from a cross-app import made the worker boot fail silently from the user's perspective. Cause was only visible in worker logs.
### 3. Resume/Dismiss/chat boundary
Canceled run → user clicks Dismiss → `aiAssistantApi.currentRun = null` → `panelMode` flips to `planning` → chat input returns. From here the user can type anything, and the model handles it as a fresh chat turn — including content edits — without the prompt-level "this was canceled, reconcile first" hint being load-bearing.
We mitigate (3) at the prompt level only:
- `chat-history.ts:TOOLS_WITH_FULL_OUTPUT` now includes `get_course_structure` and `get_exercise_details` so the model can see what was already created.
- `teacher.ts` has the "Re-engaging a course where an approved plan exists" rule telling the model to call `get_course_structure` first.
Both are prompt-trust mitigations. Neither is enforcement.
## Tools that are shared between modes
Every tool defined in `chat-tools.ts` is callable from both endpoints. The notable mutation-heavy ones:
- `update_lesson_content` — writes full lesson HTML. A single call can be 5–20k tokens of output.
- `add_questions` / `update_questions` — can add/update arbitrary-sized question batches in one call.
- `create_lesson`, `update_lesson`, `create_exercise`, `update_exercise`.
- `update_course_landing_page` — full landing-page mutation, including image generation.
- `go_live_course` — publishes the course.
Reads (`get_course_structure`, `get_lesson_content`, `get_exercise_details`) are also shared and have no execution-cost concern.
## Recommended hardening (not implemented)
Three bite-sized steps that move the routing decision from prompt-land into code-land:
1. **Gate heavy tools to run-mode at the API layer.** Whitelist a small set of cheap chat-mode tools; reject the rest from `/agent/chat` with a structured error that the model can read and translate into "I need to make a plan for this." This stops mode drift.
2. **Surface run health.** If `currentRun.status === 'queued'` for >30s with no `lastHeartbeat`, render a "Worker not responding" affordance with a retry button. Stops silent stalls.
3. **Tighten the chat-after-cancel boundary.** Either keep the canceled-run pane visible alongside the chat input (so Resume is always one click away) or disable chat input until the user explicitly Dismisses. Removes the "type continue and hope the prompt does the right thing" failure mode.
None of these unify the two modes — that's a multi-month rewrite. They just stop the LLM and the worker queue from being single points of failure for routing.
@@ -4,9 +4,19 @@
import ChatHeader from '$features/ai-assistant/chat-header.svelte';
import ChatMessageList from '$features/ai-assistant/chat-message-list.svelte';
import ChatInput from '$features/ai-assistant/chat-input.svelte';
import RunMonitorPane from '$features/ai-assistant/run-monitor-pane.svelte';
import ChatChangesCard from '$features/ai-assistant/chat-changes-card.svelte';
import {
buildRunSummaryMetadata,
extractChangedItems,
extractChangedItemsFromMessages,
formatRunSummaryCounts,
type ExerciseTitleLookup
} from '$features/ai-assistant/utils/run-summary';
import type { AiAssistantRunSummaryMetadata, RunChangedItem } from '$features/ai-assistant/utils/types';
import { resolve } from '$app/paths';
import { getCompletedToolLine, getPendingToolLine, MUTATION_TOOLS } from '$features/ai-assistant/utils/tool-labels';
import type { ProgressStep } from '$features/ai-assistant/utils/tool-labels';
import type { ProgressStep, ToolLineUi } from '$features/ai-assistant/utils/tool-labels';
import {
getAgentToolInput,
getAgentToolName,
@@ -43,6 +53,7 @@
AiAssistantMessage,
AiAssistantMessageMetadata,
AiAssistantTemplateMetadata,
AgentRunStep,
UploadedDocument
} from '$features/ai-assistant/utils/types';
import { getCourseTemplate, type CourseTemplateId, type TemplateFormField } from '@cio/ai-assistant';
@@ -66,7 +77,30 @@
let agentMutationProgressThresholdsTriggered = new SvelteSet<number>();
let lastSeenStreamingFlag = false;
const CONTINUE_IMPLEMENTATION_PROMPT = 'Continue implementing the plan from where you left off.';
/** Tracks the active durable run so we reset thresholds when a new run starts. */
let lastSeenDurableRunId = '';
/** Last step count seen for the active durable run; lets us skip recompute on no-op polls. */
let lastSeenDurableStepCount = 0;
/** Run ids we've already injected a synthetic summary message for in this session. */
let lastInjectedRunSummaryId = '';
/**
* Statuses that warrant injecting a synthetic summary message into the
* conversation. Only `completed` is auto-injected — canceled runs are
* resumable, so the summary is deferred until the user explicitly
* dismisses the canceled run.
*/
const INJECTABLE_RUN_STATUSES = new Set(['completed']);
/**
* Statuses that keep the panel in run-mode (composer hidden). `canceled`
* stays here so the user can either Resume or Dismiss — only `completed`
* unconditionally flips back to planning.
*/
const RUN_MODE_STATUSES = new Set(['queued', 'running', 'waiting_for_input', 'paused', 'failed', 'canceled']);
const RUN_POLL_INTERVAL_MS = 3_000;
const RUN_POLL_STATUSES = new Set(['queued', 'running', 'waiting_for_input']);
const RUN_RESUMABLE_STATUSES = new Set(['paused', 'waiting_for_input', 'canceled']);
const RUN_TERMINAL_STATUSES = new Set(['completed', 'failed', 'canceled', 'paused']);
// Read course id from the route. The chat panel is only mounted inside the
// course content layout, so `page.params.id` is always the active course.
@@ -84,7 +118,10 @@
let statusFetchedForCourseId: string | null = $state(null);
let conversationsLoadedForCourseId: string | null = $state(null);
let runsLoadedForCourseId: string | null = $state(null);
let lastRunStatusSig = $state('');
let activeConversationId: string | null = $state(null);
let isLoadingHistory = $state(false);
let selectedModel: AgentModelId = $state(DEFAULT_PICKER_MODEL_ID);
const paidModelIds = UI_PICKER_MODEL_IDS.filter((id) => !AGENT_MODELS[id].isFree);
@@ -153,15 +190,31 @@
}
}
let pendingLoadId: string | null = null;
async function loadConversation(conversationId: string) {
if (!courseId) return;
pendingLoadId = conversationId;
await aiAssistantApi.loadConversation(conversationId);
// Another switch was requested while this one was in flight — drop this result.
if (pendingLoadId !== conversationId) {
return;
}
const conversation = aiAssistantApi.currentConversation;
if (conversation) {
if (!conversation) {
return;
}
if (chat.status === 'streaming' || chat.status === 'submitted') {
return;
}
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
@@ -169,9 +222,9 @@
if (loadedMessages.length > 0) {
chat.messages = loadedMessages;
}
setActiveConversationId(courseId, conversationId);
}
}
async function startNewChat() {
if (!courseId) return;
@@ -232,21 +285,42 @@
return;
}
// Content Ask AI bar (and org home) set this before opening the panel; loading a
// saved conversation would race handleSend and wipe the optimistic user message.
const pendingPrompt = get(initialChatPrompt);
if (pendingPrompt?.trim()) {
return;
}
const listForCourseId = courseId;
const savedId = getActiveConversationId(listForCourseId);
aiAssistantApi.listConversations(listForCourseId).then(() => {
isLoadingHistory = true;
aiAssistantApi
.listConversations(listForCourseId)
.then(async () => {
if (courseId !== listForCourseId) return;
if (activeConversationId) return;
if (chat.status === 'streaming' || chat.status === 'submitted') return;
if (savedId) {
loadConversation(savedId);
await loadConversation(savedId);
} else if (aiAssistantApi.conversations.length > 0) {
loadConversation(aiAssistantApi.conversations[0].id);
await loadConversation(aiAssistantApi.conversations[0].id);
}
})
.finally(() => {
if (courseId === listForCourseId) {
isLoadingHistory = false;
}
});
}
if (runsLoadedForCourseId !== courseId) {
runsLoadedForCourseId = courseId;
void aiAssistantApi.loadActiveRun(courseId);
}
});
function refreshCourseStateAfterChat() {
@@ -503,26 +577,146 @@
chat.stop();
}
function isRunActive(status: string | undefined): boolean {
return status === 'queued' || status === 'running' || status === 'waiting_for_input';
}
function getRunToolName(step: AgentRunStep): string | null {
if (step.stepType.startsWith('tool:')) {
return step.stepType.slice('tool:'.length);
}
const toolName = step.input?.toolName;
return typeof toolName === 'string' && toolName.length > 0 ? toolName : null;
}
function getRunStepInput(step: AgentRunStep): unknown {
const args = step.input?.args;
return args ?? step.input ?? undefined;
}
function getRunStepResult(step: AgentRunStep): unknown {
const output = step.output;
if (!output || typeof output !== 'object') {
return output ?? undefined;
}
if (Object.prototype.hasOwnProperty.call(output, 'result')) {
return (output as Record<string, unknown>).result;
}
return output;
}
function getRunProgressStatus(status: string): ProgressStep['status'] {
if (status === 'completed') return 'completed';
if (status === 'running') return 'in_progress';
if (status === 'failed' || status === 'canceled') return 'failed';
return 'pending';
}
function toRunProgressStep(step: AgentRunStep): ProgressStep {
const toolName = getRunToolName(step);
const status = getRunProgressStatus(step.status);
if (!toolName) {
return {
status,
line: { shape: 'i18n', key: 'ai_assistant.run_checkpoint' }
};
}
return {
status,
line:
status === 'completed'
? getCompletedToolLine(toolName, getRunStepResult(step))
: getPendingToolLine(toolName, getRunStepInput(step))
};
}
async function handleImplementPlan(editedPlan: unknown) {
if (chat.status === 'streaming') return;
if (!courseId) return;
const existingRunStatus = aiAssistantApi.currentRun?.run.status;
if (isRunActive(existingRunStatus)) return;
const conversationId = await ensureActiveConversation(courseId);
if (!conversationId) return;
if (!editedPlan || typeof editedPlan !== 'object') return;
inputValue = '';
await persistFinishedChat(chat.messages as AiAssistantMessage[], conversationId);
chat.sendMessage({
text: 'Implement this plan.',
metadata: {
plan: {
action: 'implement_course_plan',
payload: editedPlan
}
}
await aiAssistantApi.createRun({
courseId,
conversationId,
approvedPlan: editedPlan as Record<string, unknown>,
model: selectedModel
});
}
async function handleCancelRun() {
const runId = aiAssistantApi.currentRun?.run.id;
if (!runId) return;
await aiAssistantApi.cancelRun(runId);
refreshCourseStateAfterChat();
}
async function handleRetryRun() {
const runId = aiAssistantApi.currentRun?.run.id;
if (!runId) return;
await aiAssistantApi.retryRun(runId);
}
async function handleResumeRun() {
const runId = aiAssistantApi.currentRun?.run.id;
if (!runId) return;
await aiAssistantApi.resumeRun(runId);
}
/**
* Walk away from a canceled run: inject the run-summary message into the
* conversation (the same one we used to auto-write on cancel) and clear
* the local currentRun so the panel flips back to planning. The run row
* itself stays in the DB — user could still find it in run history.
*/
async function handleDismissRun() {
const runDetail = aiAssistantApi.currentRun;
if (!runDetail || !activeConversationId) return;
if (runDetail.run.conversationId !== activeConversationId) return;
const summaryMeta = buildRunSummaryMetadata({ runDetail, resolveExerciseTitle });
if (summaryMeta) {
const alreadyInConversation = (chat.messages as AiAssistantMessage[]).some(
(msg) => (msg.metadata as AiAssistantMessageMetadata | undefined)?.runSummary?.runId === summaryMeta.runId
);
if (!alreadyInConversation) {
const summaryMessage: AiAssistantMessage = {
id: crypto.randomUUID(),
role: 'assistant',
parts: [{ type: 'text', text: buildRunSummaryText(summaryMeta) }],
metadata: { runSummary: summaryMeta } satisfies AiAssistantMessageMetadata
};
chat.messages = [...(chat.messages as AiAssistantMessage[]), summaryMessage];
lastInjectedRunSummaryId = summaryMeta.runId;
void aiAssistantApi.saveMessages(activeConversationId, chat.messages as AiAssistantMessage[]);
}
}
aiAssistantApi.currentRun = null;
}
async function handleAskPlanChanges(message: string) {
const trimmed = message.trim();
@@ -535,11 +729,6 @@
chat.sendMessage({ text: trimmed });
}
function handleResume() {
inputValue = CONTINUE_IMPLEMENTATION_PROMPT;
void handleSend();
}
function handleMentionClick(route: string) {
goto(resolve(route, {}));
}
@@ -686,6 +875,263 @@
return { steps, currentActionLine, isStopped, titleKey, hasMutations };
});
const durableRunState = $derived.by(() => {
const runDetail = aiAssistantApi.currentRun;
if (!runDetail || runDetail.run.courseId !== courseId) {
return null;
}
const run = runDetail.run;
const steps = runDetail.steps.map(toRunProgressStep);
const titleKey =
run.status === 'completed'
? 'ai_assistant.run_completed'
: run.status === 'failed'
? 'ai_assistant.run_failed'
: run.status === 'canceled'
? 'ai_assistant.run_canceled'
: 'ai_assistant.run_running';
const waitingLine: ToolLineUi | undefined =
steps.length === 0 && run.status !== 'failed' && run.status !== 'canceled'
? { shape: 'i18n' as const, key: 'ai_assistant.run_waiting_for_worker' }
: undefined;
const currentActionLine = steps.find((step) => step.status === 'in_progress')?.line ?? waitingLine;
return {
titleKey,
steps,
currentActionLine,
isStopped: run.status === 'failed' || run.status === 'canceled' || run.status === 'paused',
error: run.lastError?.message,
canCancel: run.status === 'queued' || run.status === 'running' || run.status === 'waiting_for_input',
canRetry: run.status === 'failed',
canResume: RUN_RESUMABLE_STATUSES.has(run.status)
};
});
/**
* Decides what the bottom of the chat panel is showing: the composer
* (`planning`) or the durable run monitor (`running`). The run must belong
* to the active conversation — a paused run on a different conversation
* should not hijack this one. Completed/canceled runs flip back to
* planning even if `currentRun` is still in memory.
*/
const panelMode = $derived.by<'planning' | 'running'>(() => {
const runDetail = aiAssistantApi.currentRun;
if (!runDetail) return 'planning';
if (runDetail.run.courseId !== courseId) return 'planning';
if (activeConversationId && runDetail.run.conversationId !== activeConversationId) return 'planning';
return RUN_MODE_STATUSES.has(runDetail.run.status) ? 'running' : 'planning';
});
/**
* True when any historical `PlanView` card in this conversation should default
* to its collapsed form. We minimize either when the plan card has been
* pushed back by newer assistant content (no longer the latest message) or
* the moment implementation kicks off — both signal the user's attention is
* elsewhere. `PlanView` itself preserves manual expand/collapse overrides.
*/
const planShouldCollapse = $derived(panelMode === 'running');
/** Statuses where the worker is still actively executing — hide the changes card. */
const ACTIVELY_EXECUTING = new Set(['queued', 'running', 'waiting_for_input']);
/**
* Build a map of every exercise id → title from the loaded course outline so
* the changes extractor can resolve titles for exercise-section actions
* (which only expose the parent exercise id, not its title).
*/
const resolveExerciseTitle = $derived.by<ExerciseTitleLookup>(() => {
const course = courseApi.course;
if (!course?.content) return () => undefined;
const titles = new Map<string, string>();
const visitItems = (items: { id: string; title?: string; type?: unknown }[] | undefined) => {
if (!items) return;
for (const item of items) {
// Heuristic: only the exercise content type has a route-relevant id we care about.
// We index everything that has a title — the lookup is exercise-scoped at call time.
if (item.id && typeof item.title === 'string') {
titles.set(item.id, item.title);
}
}
};
visitItems(course.content.items as unknown as { id: string; title?: string; type?: unknown }[] | undefined);
for (const section of course.content.sections ?? []) {
visitItems((section as unknown as { items?: { id: string; title?: string; type?: unknown }[] }).items);
}
return (exerciseId: string) => titles.get(exerciseId);
});
/**
* Per-item changes the agent just made to this conversation's content.
* Three sources, in priority order:
* 1. The active background run, once it has stopped executing — covers
* Agent-mode work (BullMQ runner) with a Completed/Canceled/Paused status.
* 2. The latest persisted `runSummary.changes` on a message — survives
* refresh for runs that have already injected their summary message.
* 3. Inline chat-mode tool calls aggregated from `chat.messages` — covers
* conversations where the agent updated content via tool calls embedded
* directly in assistant messages (no background run involved). Carries
* no status label.
*/
const changedItems = $derived.by<RunChangedItem[]>(() => {
const runDetail = aiAssistantApi.currentRun;
if (runDetail && runDetail.run.conversationId === activeConversationId) {
if (!ACTIVELY_EXECUTING.has(runDetail.run.status)) {
return extractChangedItems(runDetail, resolveExerciseTitle);
}
}
const messages = chat.messages as AiAssistantMessage[];
for (let i = messages.length - 1; i >= 0; i--) {
const summary = (messages[i].metadata as AiAssistantMessageMetadata | undefined)?.runSummary;
if (summary?.changes && summary.changes.length > 0) {
return summary.changes;
}
}
return extractChangedItemsFromMessages(messages, resolveExerciseTitle);
});
function runStatusLabel(status: string): string {
if (status === 'completed') return t.get('ai_assistant.changes_card.status.completed');
if (status === 'failed') return t.get('ai_assistant.changes_card.status.failed');
if (status === 'canceled') return t.get('ai_assistant.changes_card.status.canceled');
if (status === 'paused') return t.get('ai_assistant.changes_card.status.paused');
return '';
}
/** Status label shown next to the count in the changes card header ("Completed", …). */
const changedItemsStatusLabel = $derived.by<string | null>(() => {
const runDetail = aiAssistantApi.currentRun;
if (runDetail && runDetail.run.conversationId === activeConversationId) {
if (!ACTIVELY_EXECUTING.has(runDetail.run.status)) {
return runStatusLabel(runDetail.run.status) || null;
}
}
const messages = chat.messages as AiAssistantMessage[];
for (let i = messages.length - 1; i >= 0; i--) {
const summary = (messages[i].metadata as AiAssistantMessageMetadata | undefined)?.runSummary;
if (summary?.changes && summary.changes.length > 0) {
return runStatusLabel(summary.status) || null;
}
}
return null;
});
function buildRunSummaryText(metadata: AiAssistantRunSummaryMetadata): string {
const countsText = formatRunSummaryCounts(metadata.counts);
if (metadata.status === 'completed') {
return countsText
? t.get('ai_assistant.run_summary.completed_with_counts', { counts: countsText })
: t.get('ai_assistant.run_summary.completed');
}
if (metadata.status === 'canceled') {
return countsText
? t.get('ai_assistant.run_summary.canceled_with_counts', { counts: countsText })
: t.get('ai_assistant.run_summary.canceled');
}
if (metadata.status === 'failed') {
return metadata.error
? t.get('ai_assistant.run_summary.failed_with_reason', { reason: metadata.error })
: t.get('ai_assistant.run_summary.failed');
}
return countsText
? t.get('ai_assistant.run_summary.paused_with_counts', { counts: countsText })
: t.get('ai_assistant.run_summary.paused');
}
/**
* Inject a synthetic assistant message summarising the run the first time
* we observe it reach a settled terminal status. Persists so a refresh
* still surfaces the summary — and dedupes via metadata.runSummary.runId
* so we don't double-write across refreshes.
*/
$effect(() => {
const runDetail = aiAssistantApi.currentRun;
if (!runDetail) return;
if (runDetail.run.courseId !== courseId) return;
if (!activeConversationId) return;
if (runDetail.run.conversationId !== activeConversationId) return;
if (!INJECTABLE_RUN_STATUSES.has(runDetail.run.status)) return;
const runId = runDetail.run.id;
if (runId === lastInjectedRunSummaryId) return;
const alreadyInConversation = (chat.messages as AiAssistantMessage[]).some(
(msg) => (msg.metadata as AiAssistantMessageMetadata | undefined)?.runSummary?.runId === runId
);
if (alreadyInConversation) {
lastInjectedRunSummaryId = runId;
return;
}
const summaryMeta = buildRunSummaryMetadata({ runDetail, resolveExerciseTitle });
if (!summaryMeta) return;
const summaryMessage: AiAssistantMessage = {
id: crypto.randomUUID(),
role: 'assistant',
parts: [{ type: 'text', text: buildRunSummaryText(summaryMeta) }],
metadata: { runSummary: summaryMeta } satisfies AiAssistantMessageMetadata
};
chat.messages = [...(chat.messages as AiAssistantMessage[]), summaryMessage];
lastInjectedRunSummaryId = runId;
void aiAssistantApi.saveMessages(activeConversationId, chat.messages as AiAssistantMessage[]);
});
$effect(() => {
const run = aiAssistantApi.currentRun?.run;
if (!run) {
lastRunStatusSig = '';
return;
}
const nextSig = `${run.id}:${run.status}`;
if (nextSig === lastRunStatusSig) {
return;
}
const previousSig = lastRunStatusSig;
lastRunStatusSig = nextSig;
if (previousSig.startsWith(`${run.id}:`) && RUN_TERMINAL_STATUSES.has(run.status)) {
refreshCourseStateAfterChat();
}
});
$effect(() => {
const run = aiAssistantApi.currentRun?.run;
if (!run || !RUN_POLL_STATUSES.has(run.status)) {
return;
}
const pollTimer = window.setInterval(() => {
void aiAssistantApi.loadRun(run.id);
}, RUN_POLL_INTERVAL_MS);
return () => window.clearInterval(pollTimer);
});
$effect(() => {
const streamingNow = isStreaming;
@@ -729,6 +1175,49 @@
}
});
// Mirrors the inline-streaming threshold refresh for durable background runs
// (the /agent/runs path). Recomputes only when the run's step count actually
// changes, so 2s polls that bring no new steps don't trigger work.
$effect(() => {
const runDetail = aiAssistantApi.currentRun;
if (!runDetail || runDetail.run.courseId !== courseId || !RUN_POLL_STATUSES.has(runDetail.run.status)) {
lastSeenDurableRunId = '';
lastSeenDurableStepCount = 0;
return;
}
const runId = runDetail.run.id;
if (runId !== lastSeenDurableRunId) {
agentMutationProgressThresholdsTriggered.clear();
lastSeenDurableRunId = runId;
lastSeenDurableStepCount = 0;
}
const total = runDetail.steps.length;
if (total === 0 || total === lastSeenDurableStepCount) {
return;
}
lastSeenDurableStepCount = total;
const completedStepCount = runDetail.steps.filter((step) => step.status === 'completed').length;
const stepThresholds = [
...new Set(
AGENT_STEP_PROGRESS_REFRESH_RATIOS.map((ratio) => Math.min(total, Math.max(1, Math.ceil(total * ratio))))
)
].sort((a, b) => a - b);
for (const threshold of stepThresholds) {
if (completedStepCount >= threshold && !agentMutationProgressThresholdsTriggered.has(threshold)) {
agentMutationProgressThresholdsTriggered.add(threshold);
refreshCourseStateAfterChat();
}
}
});
const conversationTitle = $derived(
aiAssistantApi.conversations.find((c) => c.id === activeConversationId)?.title ?? null
);
@@ -818,9 +1307,11 @@
<ChatMessageList
messages={chat.messages}
{isStreaming}
{isLoadingHistory}
{isStudent}
{courseId}
{planExecutionState}
{planShouldCollapse}
{quickActions}
onQuickAction={handleQuickAction}
onImplementPlan={handleImplementPlan}
@@ -828,10 +1319,30 @@
onSubmitTemplateAnswers={handleSubmitTemplateAnswers}
onSkipTemplateForm={handleSkipTemplateForm}
onStop={handleStop}
onResume={handleResume}
onMentionClick={handleMentionClick}
/>
{#if panelMode === 'running' && durableRunState}
<RunMonitorPane
state={durableRunState}
{courseId}
onCancel={handleCancelRun}
onResume={handleResumeRun}
onRetry={handleRetryRun}
onDismiss={handleDismissRun}
runStatus={aiAssistantApi.currentRun?.run.status ?? null}
queuedSince={aiAssistantApi.currentRun?.run.createdAt ?? null}
onMentionClick={handleMentionClick}
/>
{:else}
{#if changedItems.length > 0}
<ChatChangesCard
items={changedItems}
{courseId}
onNavigate={handleMentionClick}
statusLabel={changedItemsStatusLabel}
/>
{/if}
<ChatInput
bind:inputValue
{isStreaming}
@@ -851,4 +1362,5 @@
onSelectModel={handleSelectModel}
onLockedModelSelect={handleLockedModelSelect}
/>
{/if}
</div>
@@ -3,16 +3,21 @@ import type {
AgentConversation,
AgentConversationCreateData,
AgentConversationSummary,
AgentRunDetail,
AgentRunSummary,
AgentStatusData,
AiAssistantMessage,
CompactConversationRequest,
CompactConversationSuccess
} from '../utils/types';
import type { AgentModelId } from '@cio/utils/agent-models';
class AiAssistantApi extends BaseApiWithErrors {
status: AgentStatusData | null = $state(null);
conversations: AgentConversationSummary[] = $state([]);
currentConversation: AgentConversation | null = $state(null);
runs: AgentRunSummary[] = $state([]);
currentRun: AgentRunDetail | null = $state(null);
async fetchStatus(courseId: string) {
await this.execute<typeof classroomio.agent.status.$get>({
@@ -250,6 +255,144 @@ class AiAssistantApi extends BaseApiWithErrors {
return compacted;
}
async listRuns(courseId: string): Promise<AgentRunSummary[]> {
let runs: AgentRunSummary[] = [];
await this.execute<typeof classroomio.agent.runs.$get>({
requestFn: () =>
classroomio.agent.runs.$get({
query: { courseId }
}),
logContext: 'listing agent runs',
onSuccess: (result) => {
runs = result.data as AgentRunSummary[];
this.runs = runs;
}
});
return runs;
}
async loadActiveRun(courseId: string): Promise<AgentRunDetail | null> {
const runs = await this.listRuns(courseId);
const active = runs.find((run) =>
['queued', 'running', 'waiting_for_input', 'paused', 'failed'].includes(run.status)
);
if (!active) {
this.currentRun = null;
return null;
}
return this.loadRun(active.id);
}
async createRun(input: {
courseId: string;
conversationId: string;
approvedPlan: Record<string, unknown>;
model: AgentModelId;
}): Promise<AgentRunDetail | null> {
let runDetail: AgentRunDetail | null = null;
await this.execute<typeof classroomio.agent.runs.$post>({
requestFn: () =>
classroomio.agent.runs.$post({
json: {
courseId: input.courseId,
conversationId: input.conversationId,
approvedPlan: input.approvedPlan,
model: input.model,
phase: 'implementing'
}
}),
logContext: 'creating agent run',
onSuccess: (result) => {
runDetail = result.data as AgentRunDetail;
this.currentRun = runDetail;
this.runs = [runDetail.run, ...this.runs.filter((run) => run.id !== runDetail.run.id)];
}
});
return runDetail;
}
async loadRun(runId: string): Promise<AgentRunDetail | null> {
let runDetail: AgentRunDetail | null = null;
await this.execute<(typeof classroomio.agent.runs)[':runId']['$get']>({
requestFn: () =>
classroomio.agent.runs[':runId'].$get({
param: { runId }
}),
logContext: 'loading agent run',
onSuccess: (result) => {
runDetail = result.data as AgentRunDetail;
this.currentRun = runDetail;
this.runs = this.runs.map((run) => (run.id === runDetail!.run.id ? runDetail!.run : run));
}
});
return runDetail;
}
async cancelRun(runId: string): Promise<AgentRunDetail | null> {
let runDetail: AgentRunDetail | null = null;
await this.execute<(typeof classroomio.agent.runs)[':runId']['cancel']['$post']>({
requestFn: () =>
classroomio.agent.runs[':runId'].cancel.$post({
param: { runId }
}),
logContext: 'canceling agent run',
onSuccess: (result) => {
runDetail = result.data as AgentRunDetail;
this.currentRun = runDetail;
this.runs = this.runs.map((run) => (run.id === runDetail!.run.id ? runDetail!.run : run));
}
});
return runDetail;
}
async retryRun(runId: string): Promise<AgentRunDetail | null> {
let runDetail: AgentRunDetail | null = null;
await this.execute<(typeof classroomio.agent.runs)[':runId']['retry']['$post']>({
requestFn: () =>
classroomio.agent.runs[':runId'].retry.$post({
param: { runId }
}),
logContext: 'retrying agent run',
onSuccess: (result) => {
runDetail = result.data as AgentRunDetail;
this.currentRun = runDetail;
this.runs = this.runs.map((run) => (run.id === runDetail!.run.id ? runDetail!.run : run));
}
});
return runDetail;
}
async resumeRun(runId: string): Promise<AgentRunDetail | null> {
let runDetail: AgentRunDetail | null = null;
await this.execute<(typeof classroomio.agent.runs)[':runId']['resume']['$post']>({
requestFn: () =>
classroomio.agent.runs[':runId'].resume.$post({
param: { runId }
}),
logContext: 'resuming agent run',
onSuccess: (result) => {
runDetail = result.data as AgentRunDetail;
this.currentRun = runDetail;
this.runs = this.runs.map((run) => (run.id === runDetail!.run.id ? runDetail!.run : run));
}
});
return runDetail;
}
}
export const aiAssistantApi = new AiAssistantApi();
@@ -0,0 +1,90 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { LessonIcon, ExerciseIcon } from '@cio/ui/custom/moving-icons';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { t } from '$lib/utils/functions/translations';
import type { RunChangedItem } from '$features/ai-assistant/utils/types';
interface Props {
items: RunChangedItem[];
courseId: string;
onNavigate: (route: string) => void;
/** Optional status label rendered next to the count ("Completed", "Canceled", …). */
statusLabel?: string | null;
}
let { items, courseId, onNavigate, statusLabel = null }: Props = $props();
let isExpanded = $state(true);
function toggle() {
isExpanded = !isExpanded;
}
function pathFor(item: RunChangedItem): string {
if (item.targetType === 'lesson') {
return resolve(`/courses/${courseId}/lessons/${item.targetId}`, {});
}
return resolve(`/courses/${courseId}/exercises/${item.targetId}`, {});
}
function formatTokens(n: number): string {
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
return String(n);
}
</script>
<div class="ui:bg-background rounded-t-lg border-t text-xs">
<button
type="button"
onclick={toggle}
class="hover:ui:bg-muted/40 flex w-full cursor-pointer items-center gap-2 rounded-t-lg px-3 py-1.5 text-left"
aria-expanded={isExpanded}
aria-label={isExpanded ? $t('ai_assistant.changes_card.collapse') : $t('ai_assistant.changes_card.expand')}
>
{#if isExpanded}
<ChevronDownIcon size={12} class="ui:text-muted-foreground shrink-0" />
{:else}
<ChevronRightIcon size={12} class="ui:text-muted-foreground shrink-0" />
{/if}
<span class="ui:text-foreground font-medium">
{$t('ai_assistant.changes_card.summary_count', { count: items.length })}
</span>
{#if statusLabel}
<span class="ui:text-muted-foreground">· {statusLabel}</span>
{/if}
</button>
{#if isExpanded}
<ul class="max-h-48 overflow-y-auto">
{#each items as item (item.targetType + ':' + item.targetId)}
<li>
<button
type="button"
onclick={() => onNavigate(pathFor(item))}
class="hover:ui:bg-muted/40 flex w-full cursor-pointer items-center gap-2 px-3 py-1 text-left"
>
{#if item.targetType === 'lesson'}
<LessonIcon size={12} />
{:else}
<ExerciseIcon size={12} />
{/if}
<span
class="ui:text-primary min-w-0 flex-1 truncate font-medium underline decoration-dotted underline-offset-2 hover:opacity-80"
>
{item.title}
</span>
{#if item.tokens}
<span class="ui:text-muted-foreground shrink-0 tabular-nums">{formatTokens(item.tokens)}</span>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>
@@ -1,7 +1,7 @@
<script lang="ts">
import SparklesIcon from '@lucide/svelte/icons/sparkles';
import LoaderIcon from '@lucide/svelte/icons/loader';
import { Button } from '@cio/ui/base/button';
import { Spinner } from '@cio/ui/base/spinner';
import MessageBubble from '$features/ai-assistant/message-bubble.svelte';
import ProgressCard from '$features/ai-assistant/progress-card.svelte';
import { t } from '$lib/utils/functions/translations';
@@ -26,9 +26,12 @@
interface Props {
messages: AiAssistantMessage[];
isStreaming: boolean;
isLoadingHistory: boolean;
isStudent: boolean;
courseId: string;
planExecutionState: PlanExecutionState | null;
/** When true (e.g. implementation has started), historical PlanView cards default to collapsed. */
planShouldCollapse?: boolean;
quickActions: QuickActionOption[];
onQuickAction: (action: string) => void;
onImplementPlan: (editedPlan: unknown) => void;
@@ -40,16 +43,17 @@
}) => void;
onSkipTemplateForm: (payload: { templateId: CourseTemplateId }) => void;
onStop: () => void;
onResume: () => void;
onMentionClick: (route: string) => void;
}
let {
messages,
isStreaming,
isLoadingHistory,
isStudent,
courseId,
planExecutionState,
planShouldCollapse = false,
quickActions,
onQuickAction,
onImplementPlan,
@@ -57,7 +61,6 @@
onSubmitTemplateAnswers,
onSkipTemplateForm,
onStop,
onResume,
onMentionClick
}: Props = $props();
@@ -152,7 +155,11 @@
</script>
<div class="flex-1 overflow-y-auto p-4" bind:this={messagesContainer} onscroll={handleScroll}>
{#if isEmpty}
{#if isLoadingHistory}
<div class="flex h-full items-center justify-center">
<Spinner class="ui:size-6 ui:text-muted-foreground" />
</div>
{:else if isEmpty}
<!-- Empty state with quick actions -->
<div class="flex h-full flex-col items-center justify-center gap-4">
<div class="flex flex-col items-center gap-2 text-center">
@@ -192,6 +199,7 @@
{courseId}
{isStreaming}
{isLast}
{planShouldCollapse}
{onImplementPlan}
{onAskPlanChanges}
{onSubmitTemplateAnswers}
@@ -219,11 +227,6 @@
onStop={isStreaming ? onStop : undefined}
isStopped={planExecutionState.isStopped}
/>
{#if planExecutionState.isStopped}
<Button size="sm" variant="outline" onclick={onResume} class="mt-2 w-full"
>{$t('ai_assistant.resume')}</Button
>
{/if}
</div>
{/if}
</div>
@@ -0,0 +1,165 @@
<script lang="ts">
import { browser } from '$app/environment';
import { tick } from 'svelte';
import { fly, fade } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import { Button } from '@cio/ui/base/button';
import { Textarea } from '@cio/ui/base/textarea';
import { Separator } from '@cio/ui/base/separator';
import * as Kbd from '@cio/ui/base/kbd';
import { t } from '$lib/utils/functions/translations';
import { sendPromptToAssistant } from '$features/ai-assistant/utils/store';
interface Props {
/** Width classes matching the page content column (e.g. mx-auto w-full max-w-3xl). */
class?: string;
}
let { class: contentClass = 'mx-auto w-full max-w-3xl' }: Props = $props();
const pageShellClass = 'mx-auto w-[90%] max-w-4xl px-3 sm:px-4 lg:max-w-5xl';
let expanded = $state(false);
let draft = $state('');
let textareaRef: HTMLTextAreaElement | null = $state(null);
const isMac = $derived(browser && /Mac|iPhone|iPad|iPod/.test(navigator.platform));
const expandIn = { y: 20, duration: 280, easing: cubicOut };
const expandOut = { y: 12, duration: 200, easing: cubicOut };
const collapseIn = { duration: 200, easing: cubicOut };
const collapseOut = { duration: 150, easing: cubicOut };
function shouldIgnoreGlobalShortcut() {
const active = document.activeElement;
if (!active) {
return false;
}
const tag = active.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') {
return true;
}
if (active instanceof HTMLElement && active.isContentEditable) {
return true;
}
return false;
}
async function expand() {
expanded = true;
await tick();
textareaRef?.focus();
}
function collapse() {
expanded = false;
}
function handleTextareaBlur() {
collapse();
}
function submit() {
const trimmed = draft.trim();
if (!trimmed) {
return;
}
sendPromptToAssistant(trimmed);
draft = '';
collapse();
}
function handleTextareaKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault();
collapse();
return;
}
if (event.key !== 'Enter' || event.shiftKey) {
return;
}
event.preventDefault();
submit();
}
function handleWindowKeydown(event: KeyboardEvent) {
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'i') {
return;
}
if (expanded) {
return;
}
if (shouldIgnoreGlobalShortcut()) {
return;
}
event.preventDefault();
void expand();
}
</script>
<svelte:window onkeydown={handleWindowKeydown} />
{#if expanded}
<div
class="ui:bg-background ui:border-border fixed right-0 bottom-0 left-0 z-40 border-t py-3 shadow-sm md:left-[var(--sidebar-width,0px)]"
in:fly={expandIn}
out:fly={expandOut}
>
<div class={pageShellClass}>
<div class={contentClass}>
<Textarea
bind:ref={textareaRef}
bind:value={draft}
rows={1}
placeholder={$t('ai_assistant.content_input_placeholder')}
aria-label={$t('ai_assistant.ask_ai_input_aria')}
class="ui:min-h-10 ui:max-h-64 ui:resize-none ui:rounded-none ui:shadow-none"
onkeydown={handleTextareaKeydown}
onblur={handleTextareaBlur}
/>
</div>
</div>
</div>
{:else}
<div
class="pointer-events-none fixed right-0 bottom-4 left-0 z-40 md:left-[var(--sidebar-width,0px)]"
in:fade={collapseIn}
out:fade={collapseOut}
>
<div class={pageShellClass}>
<div class={`flex justify-center ${contentClass}`}>
<Button
variant="outline"
class="ui:bg-background ui:shadow-sm pointer-events-auto"
aria-label={$t('ai_assistant.ask_ai_expand_aria')}
onclick={() => expand()}
onmouseenter={() => expand()}
>
{$t('ai_assistant.ask_ai')}
<Separator orientation="vertical" class="ui:!h-4" />
<span class="ui:text-muted-foreground flex items-center gap-0.5 font-normal">
{#if isMac}
<Kbd.Root>⌘</Kbd.Root>
{:else}
<Kbd.Root>Ctrl</Kbd.Root>
{/if}
<Kbd.Root>I</Kbd.Root>
</span>
</Button>
</div>
</div>
</div>
{/if}
@@ -1,4 +1,5 @@
export { default as AiCourseChat } from './ai-course-chat.svelte';
export { default as ContentAskAiBar } from './content-ask-ai-bar.svelte';
export { default as PlanView } from './plan-view.svelte';
export { default as ProgressCard } from './progress-card.svelte';
export {
@@ -8,6 +9,7 @@ export {
openAiAssistant,
closeAiAssistant,
quoteInChat,
sendPromptToAssistant,
setChatDraft,
toggleAiAssistant
} from './utils/store';
@@ -38,6 +38,8 @@
onAskPlanChanges: (message: string) => void;
isStreaming: boolean;
isLast?: boolean;
/** When true, historical PlanView cards default to their collapsed form. */
planShouldCollapse?: boolean;
onSubmitTemplateAnswers: (payload: {
templateId: CourseTemplateId;
answers: Record<string, string>;
@@ -57,9 +59,17 @@
onSkipTemplateForm,
onMentionClick,
isStreaming,
isLast = false
isLast = false,
planShouldCollapse = false
}: Props = $props();
/**
* Collapse a PlanView card when implementation has started anywhere in the
* conversation OR when newer assistant messages have pushed it back. The
* very first plan card the teacher sees stays expanded.
*/
const planCardDefaultCollapsed = $derived(planShouldCollapse || !isLast);
function localizePendingTool(toolName: string): string {
const vars = getPendingToolI18nVars(toolName);
const key = getPendingToolI18nKey(toolName);
@@ -170,6 +180,7 @@
onImplement={onImplementPlan}
onAskChanges={onAskPlanChanges}
isBusy={isStreaming}
defaultCollapsed={planCardDefaultCollapsed}
/>
</div>
{:else if toolName === 'ask_template_questions' && (toolStatus === 'completed' || toolStatus === 'in_progress')}
@@ -25,9 +25,33 @@
/** When true, disables the inline change-request composer (agent is already working). */
isBusy?: boolean;
remainingTokens?: number;
/**
* When true on mount, the card starts collapsed (header-only). When the
* prop flips from false → true and the user hasn't manually toggled the
* card, it collapses automatically. Manual user toggles are sticky.
*/
defaultCollapsed?: boolean;
}
let { plan, onImplement, onAskChanges, isBusy = false, remainingTokens }: Props = $props();
let { plan, onImplement, onAskChanges, isBusy = false, remainingTokens, defaultCollapsed = false }: Props = $props();
let isExpanded = $state(!defaultCollapsed);
/** Sticky once the user clicks the top-level chevron; defeats the auto-collapse signal. */
let userToggled = $state(false);
function toggleCard() {
userToggled = true;
isExpanded = !isExpanded;
}
/**
* When the parent flips defaultCollapsed (e.g. implementation just began),
* collapse — unless the user has explicitly opened the card already.
*/
$effect(() => {
if (userToggled) return;
isExpanded = !defaultCollapsed;
});
let changeRequest = $state('');
const canSendChange = $derived(!isBusy && changeRequest.trim().length > 0);
@@ -122,8 +146,22 @@
</script>
<div class="ui:bg-background rounded-lg border">
<!-- Plan header -->
<div class="border-b px-3 py-2">
<!-- Plan header: chevron + title + counts. Chevron toggles the whole card. -->
<div class="flex items-start gap-2 {isExpanded ? 'border-b' : ''} px-3 py-2">
<button
type="button"
onclick={toggleCard}
class="ui:text-muted-foreground hover:ui:text-foreground mt-0.5 shrink-0"
aria-label={isExpanded ? $t('ai_assistant.plan_view.collapse') : $t('ai_assistant.plan_view.expand')}
>
{#if isExpanded}
<ChevronDownIcon size={14} />
{:else}
<ChevronRightIcon size={14} />
{/if}
</button>
<div class="min-w-0 flex-1">
{#if editingField === 'plan-title'}
<input
type="text"
@@ -148,7 +186,9 @@
: ''}
</p>
</div>
</div>
{#if isExpanded}
<!-- Sections tree -->
<div class="max-h-[300px] overflow-y-auto p-2">
{#each editablePlan.sections as section, sectionIndex (sectionIndex)}
@@ -285,4 +325,5 @@
</div>
</div>
</div>
{/if}
</div>
@@ -1,4 +1,6 @@
<script lang="ts">
import { tick } from 'svelte';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import CheckIcon from '@lucide/svelte/icons/check';
import LoaderIcon from '@lucide/svelte/icons/loader';
import CircleIcon from '@lucide/svelte/icons/circle';
@@ -19,9 +21,26 @@
onStop?: () => void;
isStopped?: boolean;
error?: string;
showTitle?: boolean;
}
let { titleKey, steps, courseId, onNavigate, currentActionLine, onStop, isStopped = false, error }: Props = $props();
let {
titleKey,
steps,
courseId,
onNavigate,
currentActionLine,
onStop,
isStopped = false,
error,
showTitle = true
}: Props = $props();
/** Per-char delay for typewriter — adjusted down for long lines to stay under TYPEWRITER_MAX_MS total. */
const TYPEWRITER_MS_PER_CHAR = 25;
const TYPEWRITER_MAX_MS = 1500;
/** "At the bottom" tolerance — same idea as `chat-message-list.svelte`. */
const SCROLL_PIN_THRESHOLD = 40;
function stepRowKey(step: ProgressStep, index: number): string {
if (step.line.shape === 'i18n') {
@@ -39,32 +58,147 @@
return `${index}-exo-${step.line.exerciseId}-${step.line.action}-${step.line.count}-${step.line.title}`;
}
// ─── Typewriter ────────────────────────────────────────────────────────────
// Only i18n-shape lines animate (those are plain text). Lines containing
// mention links render in full immediately — partial-rendering a mention
// would look broken.
/** Step keys whose text has finished animating (or never needed to). */
let animatedKeys = new SvelteSet<string>();
/** key → currently-displayed substring during animation. */
let animatingTexts = new SvelteMap<string, string>();
function resolveI18nText(line: ToolLineUi): string | null {
if (line.shape !== 'i18n') return null;
return t.get(line.key, line.vars ?? {});
}
function startTypewriter(key: string, fullText: string) {
if (fullText.length === 0) {
animatedKeys.add(key);
return;
}
// Squeeze the per-char delay so even long lines finish under the cap.
const perCharDelay = Math.max(
15,
Math.min(TYPEWRITER_MS_PER_CHAR, Math.floor(TYPEWRITER_MAX_MS / fullText.length))
);
let revealed = 0;
animatingTexts.set(key, '');
const handle = setInterval(() => {
revealed += 1;
animatingTexts.set(key, fullText.slice(0, revealed));
if (revealed >= fullText.length) {
clearInterval(handle);
animatingTexts.delete(key);
animatedKeys.add(key);
}
}, perCharDelay);
}
// Detect newly arrived steps every render. Reading `steps` ties this effect
// to step list growth.
$effect(() => {
for (let i = 0; i < steps.length; i++) {
const key = stepRowKey(steps[i], i);
if (animatedKeys.has(key) || animatingTexts.has(key)) continue;
const text = resolveI18nText(steps[i].line);
if (text === null) {
// Non-i18n shape — render in full, skip the typewriter.
animatedKeys.add(key);
continue;
}
startTypewriter(key, text);
}
});
// ─── Auto-scroll ───────────────────────────────────────────────────────────
let stepsContainer: HTMLDivElement | undefined = $state();
let isPinnedToBottom = $state(true);
let lastSeenStepCount = 0;
function handleScroll() {
if (!stepsContainer) return;
const distanceFromBottom = stepsContainer.scrollHeight - stepsContainer.scrollTop - stepsContainer.clientHeight;
isPinnedToBottom = distanceFromBottom <= SCROLL_PIN_THRESHOLD;
}
$effect(() => {
if (steps.length === lastSeenStepCount) return;
const grew = steps.length > lastSeenStepCount;
lastSeenStepCount = steps.length;
if (grew && isPinnedToBottom) {
void tick().then(() => {
if (stepsContainer) {
stepsContainer.scrollTop = stepsContainer.scrollHeight;
}
});
}
});
const completedCount = $derived(steps.filter((s) => s.status === 'completed').length);
const totalCount = $derived(steps.length);
</script>
<div class="ui:bg-background rounded-lg border">
<div class="border-b px-3 py-2">
<!-- Header: title, progress count, and the prominent "now doing" strip -->
<div class="space-y-1.5 border-b px-3 py-2">
{#if showTitle}
<h4 class="text-sm font-semibold">{$t(titleKey)}</h4>
{/if}
{#if totalCount > 0}
<p class="ui:text-muted-foreground text-xs">
{$t('ai_assistant.plan_progress', { completed: completedCount, total: totalCount })}
</p>
{/if}
{#if currentActionLine && !isStopped}
<div class="ui:bg-muted/40 flex items-center gap-2 rounded-md px-2 py-1.5 text-xs">
<LoaderIcon size={14} class="ui:text-primary shrink-0 animate-spin" />
<span class="ui:text-muted-foreground shrink-0 text-[10px] font-medium tracking-wide uppercase">
{$t('ai_assistant.run_monitor.now_doing')}
</span>
<span class="ui:text-foreground min-w-0 flex-1 truncate">
<ToolLine line={currentActionLine} {courseId} {onNavigate} />
</span>
</div>
{/if}
</div>
<div class="max-h-75 space-y-0.5 overflow-y-auto p-2">
<div bind:this={stepsContainer} onscroll={handleScroll} class="max-h-75 space-y-0.5 overflow-y-auto p-2">
{#each steps as step, i (stepRowKey(step, i))}
<div class="flex items-center gap-2 rounded px-2 py-1 text-xs {step.indent ? 'ml-4' : ''}">
{@const rowKey = stepRowKey(step, i)}
{@const animatingSlice = animatingTexts.get(rowKey)}
{@const isAnimating = animatingSlice !== undefined && step.line.shape === 'i18n'}
<div
class="flex items-center gap-2 rounded px-2 py-1 text-xs {step.indent ? 'ml-4' : ''} {step.status ===
'in_progress'
? 'animate-pulse'
: ''}"
>
{#if step.status === 'completed'}
<CheckIcon size={12} class="ui:text-primary shrink-0" />
<CheckIcon size={14} class="ui:text-primary shrink-0" />
{:else if step.status === 'in_progress'}
<LoaderIcon size={12} class="ui:text-primary shrink-0 animate-spin" />
<LoaderIcon size={14} class="ui:text-primary shrink-0 animate-spin" />
{:else if step.status === 'failed'}
<AlertCircleIcon size={12} class="shrink-0 text-red-500" />
<AlertCircleIcon size={14} class="shrink-0 text-red-500" />
{:else}
<CircleIcon size={12} class="ui:text-muted-foreground shrink-0" />
<CircleIcon size={14} class="ui:text-muted-foreground shrink-0" />
{/if}
<span class={step.status === 'completed' ? '' : step.status === 'pending' ? 'ui:text-muted-foreground' : ''}>
{#if isAnimating}
{animatingSlice}
{:else}
<ToolLine line={step.line} {courseId} {onNavigate} />
{/if}
</span>
</div>
{/each}
@@ -75,12 +209,6 @@
<p class="text-xs text-red-600 dark:text-red-400">{error}</p>
{/if}
{#if currentActionLine && !isStopped}
<div class="ui:text-muted-foreground text-xs leading-snug">
<ToolLine line={currentActionLine} {courseId} {onNavigate} />
</div>
{/if}
{#if isStopped}
<p class="ui:text-muted-foreground text-xs">{$t('ai_assistant.stopped_content_kept')}</p>
{/if}
@@ -0,0 +1,185 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Button } from '@cio/ui/base/button';
import LoaderIcon from '@lucide/svelte/icons/loader';
import CheckCircleIcon from '@lucide/svelte/icons/check-circle-2';
import XCircleIcon from '@lucide/svelte/icons/x-circle';
import AlertTriangleIcon from '@lucide/svelte/icons/alert-triangle';
import PauseIcon from '@lucide/svelte/icons/pause-circle';
import ProgressCard from '$features/ai-assistant/progress-card.svelte';
import { t } from '$lib/utils/functions/translations';
import type { ProgressStep, ToolLineUi } from '$features/ai-assistant/utils/tool-labels';
interface RunMonitorState {
titleKey: string;
steps: ProgressStep[];
currentActionLine?: ToolLineUi;
isStopped: boolean;
error?: string;
canCancel: boolean;
canRetry: boolean;
canResume: boolean;
}
/** Queued for longer than this with no worker pickup = surface "worker not responding". */
const STUCK_QUEUED_THRESHOLD_MS = 30_000;
interface Props {
state: RunMonitorState;
courseId: string;
/** Underlying run status; used to enable Dismiss only on canceled runs. */
runStatus?: string | null;
/** When run is `queued`, the ISO timestamp it was enqueued. Lets us flag stuck-queued runs. */
queuedSince?: string | null;
onCancel: () => void;
onResume: () => void;
onRetry: () => void;
onDismiss: () => void;
onRetryStuck?: () => void;
onMentionClick: (route: string) => void;
}
let {
state: runState,
courseId,
runStatus = null,
queuedSince = null,
onCancel,
onResume,
onRetry,
onDismiss,
onRetryStuck,
onMentionClick
}: Props = $props();
const isCanceled = $derived(runStatus === 'canceled');
// `now` ticks every 5s so the stuck-queued detector keeps re-evaluating even
// when the run row itself isn't changing (which is exactly the symptom we're
// trying to catch — the worker isn't moving the row past `queued`).
let now = $state(Date.now());
onMount(() => {
const id = setInterval(() => (now = Date.now()), 5_000);
return () => clearInterval(id);
});
const isStuckQueued = $derived.by(() => {
if (runStatus !== 'queued' || !queuedSince) return false;
const queuedAt = Date.parse(queuedSince);
if (!Number.isFinite(queuedAt)) return false;
return now - queuedAt > STUCK_QUEUED_THRESHOLD_MS;
});
// Two-step Dismiss: first click reveals a confirm/cancel pair so the user
// can't accidentally walk away from a canceled run and lose the Resume button.
let isConfirmingDismiss = $state(false);
function requestDismiss() {
isConfirmingDismiss = true;
}
function confirmDismiss() {
isConfirmingDismiss = false;
onDismiss();
}
function cancelDismiss() {
isConfirmingDismiss = false;
}
// The titleKey already covers running / completed / failed / canceled — pick
// a matching icon so the pane state is readable at a glance without parsing
// the text.
const StatusIcon = $derived.by(() => {
if (runState.error || runState.canRetry) return XCircleIcon;
if (runState.canResume) return PauseIcon;
if (runState.isStopped) return AlertTriangleIcon;
if (runState.titleKey === 'ai_assistant.run_completed') return CheckCircleIcon;
return LoaderIcon;
});
const isWorking = $derived(!runState.isStopped && runState.titleKey !== 'ai_assistant.run_completed');
</script>
<div
class="ui:bg-background flex w-full flex-col gap-3 border-t px-4 py-3"
role="region"
aria-label={$t('ai_assistant.run_monitor_aria')}
>
<div class="ui:text-foreground flex items-center gap-2 text-sm font-medium">
<StatusIcon size={16} class={isWorking ? 'ui:text-primary animate-spin' : 'ui:text-muted-foreground'} />
<span>{$t(runState.titleKey)}</span>
</div>
<ProgressCard
titleKey={runState.titleKey}
steps={runState.steps}
currentActionLine={runState.currentActionLine}
{courseId}
onNavigate={onMentionClick}
onStop={undefined}
isStopped={runState.isStopped}
error={runState.error}
showTitle={false}
/>
{#if isStuckQueued}
<div
class="flex items-start gap-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"
role="alert"
>
<AlertTriangleIcon size={14} class="mt-0.5 shrink-0" />
<div class="min-w-0 flex-1">
<p class="font-medium">{$t('ai_assistant.run_monitor.stuck_queued.title')}</p>
<p class="mt-0.5">{$t('ai_assistant.run_monitor.stuck_queued.body')}</p>
</div>
{#if onRetryStuck}
<Button size="sm" variant="outline" onclick={onRetryStuck} class="shrink-0">
{$t('ai_assistant.retry')}
</Button>
{/if}
</div>
{/if}
<div class="flex gap-2">
{#if runState.canCancel}
<Button size="sm" variant="outline" onclick={onCancel} class="flex-1">
{$t('ai_assistant.run_monitor.cancel')}
</Button>
{/if}
{#if runState.canRetry}
<Button size="sm" variant="outline" onclick={onRetry} class="flex-1">
{$t('ai_assistant.retry')}
</Button>
{/if}
{#if runState.canResume}
<Button size="sm" variant="default" onclick={onResume} class="flex-1">
{$t('ai_assistant.resume')}
</Button>
{/if}
{#if isCanceled && !isConfirmingDismiss}
<Button size="sm" variant="ghost" onclick={requestDismiss} class="flex-1">
{$t('ai_assistant.run_monitor.dismiss')}
</Button>
{/if}
</div>
{#if isCanceled && isConfirmingDismiss}
<div class="ui:bg-muted/30 flex flex-col gap-2 rounded border px-3 py-2 text-xs">
<p class="ui:text-foreground">{$t('ai_assistant.run_monitor.dismiss_confirm_body')}</p>
<div class="flex gap-2">
<Button size="sm" variant="ghost" onclick={cancelDismiss} class="flex-1">
{$t('ai_assistant.run_monitor.dismiss_cancel')}
</Button>
<Button size="sm" variant="destructive" onclick={confirmDismiss} class="flex-1">
{$t('ai_assistant.run_monitor.dismiss_confirm')}
</Button>
</div>
</div>
{/if}
<p class="ui:text-muted-foreground text-[11px] leading-snug">
{$t('ai_assistant.run_monitor.input_disabled_hint')}
</p>
</div>
@@ -0,0 +1,43 @@
import type { Course, CourseContentItem } from '$features/course/utils/types';
import { getCourseContent } from '$features/course/utils/content';
import { ContentType } from '@cio/utils/constants/content';
function findContentItem(
course: Course | null,
contentId: string,
contentType: typeof ContentType.Lesson | typeof ContentType.Exercise
): CourseContentItem | undefined {
const content = getCourseContent(course);
const items = content.grouped ? content.sections.flatMap((section) => section.items) : content.items;
return items.find((item) => item.id === contentId && item.type === contentType);
}
export function isCourseContentLockedForStudent(
course: Course | null,
contentId: string | undefined,
contentType: typeof ContentType.Lesson | typeof ContentType.Exercise | null
): boolean {
if (!contentId || !contentType) {
return false;
}
const item = findContentItem(course, contentId, contentType);
if (!item) {
return false;
}
return (item.isUnlocked ?? true) === false;
}
export function getContentAskAiBarWidthClass(options: {
lessonId: string | undefined;
isLessonEditMode: boolean;
}): string {
if (options.lessonId) {
return options.isLessonEditMode ? 'mx-auto w-full lg:w-full xl:w-11/12' : 'mx-auto w-full max-w-3xl';
}
return 'mx-auto w-full md:max-w-3xl';
}
@@ -0,0 +1,312 @@
import type {
AgentRunDetail,
AgentRunStep,
AiAssistantMessage,
AiAssistantRunSummaryMetadata,
AiAssistantRunSummaryStatus,
RunChangeAction,
RunChangedItem
} from './types';
/**
* Look up an exercise title by id. Caller supplies the function — keeps this
* file decoupled from any specific course shape on the dashboard side.
*/
export type ExerciseTitleLookup = (exerciseId: string) => string | undefined;
const STATUSES_TERMINAL: ReadonlySet<AiAssistantRunSummaryStatus> = new Set([
'completed',
'failed',
'canceled',
'paused'
]);
export function isRunSummaryStatus(status: string | undefined | null): status is AiAssistantRunSummaryStatus {
return !!status && STATUSES_TERMINAL.has(status as AiAssistantRunSummaryStatus);
}
/**
* Aggregate tool-call counts from a run's checkpointed steps into the four
* categories the summary message surfaces (sections / lessons / exercises /
* question blocks). Anything else the worker did is intentionally omitted —
* the message should read at a glance, not log every tool call.
*/
function countToolCalls(runDetail: AgentRunDetail): AiAssistantRunSummaryMetadata['counts'] {
const counts: Required<AiAssistantRunSummaryMetadata['counts']> = {
sections: 0,
lessons: 0,
exercises: 0,
questionBlocks: 0
};
for (const step of runDetail.steps) {
if (step.status !== 'completed') continue;
const toolName = step.stepType.startsWith('tool:') ? step.stepType.slice('tool:'.length) : null;
if (!toolName) continue;
if (toolName === 'create_section') counts.sections += 1;
else if (toolName === 'create_lesson') counts.lessons += 1;
else if (toolName === 'create_exercise') counts.exercises += 1;
else if (toolName === 'add_questions') counts.questionBlocks += 1;
}
// Trim zeros so consumers can render only what was actually created.
const trimmed: AiAssistantRunSummaryMetadata['counts'] = {};
if (counts.sections) trimmed.sections = counts.sections;
if (counts.lessons) trimmed.lessons = counts.lessons;
if (counts.exercises) trimmed.exercises = counts.exercises;
if (counts.questionBlocks) trimmed.questionBlocks = counts.questionBlocks;
return trimmed;
}
function getStepToolName(step: AgentRunStep): string | null {
if (!step.stepType.startsWith('tool:')) return null;
return step.stepType.slice('tool:'.length);
}
function getStepResult(step: AgentRunStep): Record<string, unknown> | null {
const output = step.output as { result?: unknown } | null;
if (!output || typeof output !== 'object') return null;
const result = output.result;
if (!result || typeof result !== 'object') return null;
return result as Record<string, unknown>;
}
function getStepArgs(step: AgentRunStep): Record<string, unknown> | null {
const input = step.input as { args?: unknown } | null;
if (!input || typeof input !== 'object') return null;
const args = input.args;
if (!args || typeof args !== 'object') return null;
return args as Record<string, unknown>;
}
function asString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null;
}
function asNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
/**
* Add an action to an existing changed item, dedup-aware. `questions_added`
* and `questions_updated` accumulate counts; the rest are flagged once.
*/
function mergeAction(item: RunChangedItem, action: RunChangeAction): void {
if (action.kind === 'questions_added' || action.kind === 'questions_updated') {
const existing = item.actions.find((a) => a.kind === action.kind) as
| { kind: typeof action.kind; count: number }
| undefined;
if (existing) {
existing.count += action.count;
return;
}
item.actions.push(action);
return;
}
if (item.actions.some((a) => a.kind === action.kind)) return;
item.actions.push(action);
}
/**
* Source-agnostic aggregator. Caller hands a stream of `{ tool, result, args }`
* tuples (one per completed tool call) and gets back the per-item summary used
* by the changes card. Lets the same mapping table serve both run steps and
* inline chat-mode tool parts.
*/
interface ToolCallView {
tool: string;
result: Record<string, unknown> | null;
args: Record<string, unknown> | null;
}
function aggregateToolCalls(
calls: Iterable<ToolCallView>,
resolveExerciseTitle: ExerciseTitleLookup
): RunChangedItem[] {
const byKey = new Map<string, RunChangedItem>();
function upsert(
targetType: 'lesson' | 'exercise',
targetId: string,
titleFromStep: string | null,
action: RunChangeAction,
tokensDelta: number | null
): void {
const key = `${targetType}:${targetId}`;
let item = byKey.get(key);
if (!item) {
const title =
titleFromStep ?? (targetType === 'exercise' ? (resolveExerciseTitle(targetId) ?? 'Exercise') : 'Lesson');
item = { targetType, targetId, title, actions: [] };
byKey.set(key, item);
} else if (titleFromStep && (item.title === 'Lesson' || item.title === 'Exercise')) {
item.title = titleFromStep;
}
mergeAction(item, action);
if (tokensDelta !== null && tokensDelta > 0) {
item.tokens = (item.tokens ?? 0) + tokensDelta;
}
}
for (const { tool, result, args } of calls) {
if (tool === 'create_lesson' || tool === 'update_lesson') {
const id = asString(result?.id);
if (!id) continue;
upsert(
'lesson',
id,
asString(result?.title),
tool === 'create_lesson' ? { kind: 'created' } : { kind: 'metadata_updated' },
null
);
} else if (tool === 'update_lesson_content') {
const id = asString(result?.lessonId);
if (!id) continue;
const contentLength = asNumber(result?.contentLength);
const tokens = contentLength !== null ? Math.ceil(contentLength / 4) : null;
upsert('lesson', id, asString(result?.lessonTitle), { kind: 'content_written' }, tokens);
} else if (tool === 'create_exercise' || tool === 'update_exercise') {
const id = asString(result?.id);
if (!id) continue;
upsert(
'exercise',
id,
asString(result?.title),
tool === 'create_exercise' ? { kind: 'created' } : { kind: 'metadata_updated' },
null
);
} else if (tool === 'add_questions') {
const id = asString(result?.exerciseId);
if (!id) continue;
const count = asNumber(result?.addedCount) ?? 0;
if (count <= 0) continue;
upsert('exercise', id, asString(result?.exerciseTitle), { kind: 'questions_added', count }, count);
} else if (tool === 'update_questions') {
const id = asString(result?.exerciseId);
if (!id) continue;
const count = asNumber(result?.updatedCount) ?? 0;
if (count <= 0) continue;
upsert('exercise', id, asString(result?.exerciseTitle), { kind: 'questions_updated', count }, count);
} else if (tool === 'create_exercise_section' || tool === 'update_exercise_section') {
const id = asString(args?.exerciseId);
if (!id) continue;
upsert(
'exercise',
id,
null,
tool === 'create_exercise_section' ? { kind: 'section_added' } : { kind: 'section_edited' },
null
);
}
}
return Array.from(byKey.values());
}
/**
* Walk a run's completed tool steps and aggregate them into a list of unique
* lesson/exercise items with the actions performed on each. Sections,
* landing-page edits, structure reads, etc. are intentionally dropped — the
* UI surface is "what content did the teacher just get".
*/
export function extractChangedItems(
runDetail: AgentRunDetail,
resolveExerciseTitle: ExerciseTitleLookup = () => undefined
): RunChangedItem[] {
function* toCalls(): Generator<ToolCallView> {
for (const step of runDetail.steps) {
if (step.status !== 'completed') continue;
const tool = getStepToolName(step);
if (!tool) continue;
yield { tool, result: getStepResult(step), args: getStepArgs(step) };
}
}
return aggregateToolCalls(toCalls(), resolveExerciseTitle);
}
/**
* Same aggregation, but sourced from inline chat-mode tool parts on
* `AiAssistantMessage.parts`. Chat parts use `type: 'tool-<name>'`, place the
* tool's return value directly on `part.output` (no `result` wrapper), and
* carry tool args directly on `part.input` (no `args` wrapper) — so we adapt
* to those shapes before delegating to the shared aggregator.
*/
export function extractChangedItemsFromMessages(
messages: AiAssistantMessage[],
resolveExerciseTitle: ExerciseTitleLookup = () => undefined
): RunChangedItem[] {
function* toCalls(): Generator<ToolCallView> {
for (const message of messages) {
const parts = (message as { parts?: unknown }).parts;
if (!Array.isArray(parts)) continue;
for (const part of parts) {
if (!isRecord(part)) continue;
const type = typeof part.type === 'string' ? part.type : '';
if (!type.startsWith('tool-')) continue;
if (part.state !== 'output-available') continue;
const tool = type.slice('tool-'.length);
const result = isRecord(part.output) ? part.output : null;
const args = isRecord(part.input) ? part.input : null;
yield { tool, result, args };
}
}
}
return aggregateToolCalls(toCalls(), resolveExerciseTitle);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
export interface BuildRunSummaryInput {
runDetail: AgentRunDetail;
resolveExerciseTitle?: ExerciseTitleLookup;
}
/**
* Build the metadata block that gets attached to the synthetic summary
* message. Caller is responsible for formatting it into i18n text.
*/
export function buildRunSummaryMetadata({
runDetail,
resolveExerciseTitle
}: BuildRunSummaryInput): AiAssistantRunSummaryMetadata | null {
const status = runDetail.run.status;
if (!isRunSummaryStatus(status)) return null;
const changes = extractChangedItems(runDetail, resolveExerciseTitle);
return {
runId: runDetail.run.id,
status,
counts: countToolCalls(runDetail),
changes: changes.length > 0 ? changes : undefined,
finishedAt: runDetail.run.finishedAt ?? new Date().toISOString(),
error: runDetail.run.lastError?.message
};
}
export function formatRunSummaryCounts(counts: AiAssistantRunSummaryMetadata['counts']): string {
const parts: string[] = [];
if (counts.sections) parts.push(`${counts.sections} section${counts.sections === 1 ? '' : 's'}`);
if (counts.lessons) parts.push(`${counts.lessons} lesson${counts.lessons === 1 ? '' : 's'}`);
if (counts.exercises) parts.push(`${counts.exercises} exercise${counts.exercises === 1 ? '' : 's'}`);
if (counts.questionBlocks) {
parts.push(`${counts.questionBlocks} question block${counts.questionBlocks === 1 ? '' : 's'}`);
}
return parts.join(', ');
}
@@ -51,6 +51,18 @@ export function clearInitialChatModel() {
initialChatModel.set(null);
}
/** Opens the AI assistant panel and sends `prompt` as the next user message. */
export function sendPromptToAssistant(prompt: string) {
const trimmed = prompt.trim();
if (!trimmed) {
return;
}
setInitialChatPrompt(trimmed);
openAiAssistant();
}
/**
* Pending composer action picked up by the chat component:
* - `append` adds the text to whatever is already in the input (panel was open).

Some files were not shown because too many files have changed in this diff Show More