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
+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`;
}
-100
View File
@@ -1,100 +0,0 @@
import * as z from 'zod';
const envSchema = z.object({
// Legacy Cloudflare R2 config (used when OBJECT_STORAGE_* vars are absent)
CLOUDFLARE_ACCESS_KEY: z.string().optional(),
CLOUDFLARE_ACCOUNT_ID: z.string().optional(),
CLOUDFLARE_BUCKET_DOMAIN: z.string().optional(),
CLOUDFLARE_RENDERING_API_KEY: z.string().optional(),
CLOUDFLARE_SECRET_ACCESS_KEY: z.string().optional(),
CLOUDFLARE_IMAGE_BUCKET_DOMAIN: z.string().optional(),
APPROXIMATED_API_KEY: z.string().optional(),
APPROXIMATED_TARGET_ADDRESS: z.string().optional(),
/** Public IP customers point their A record to (Approximated edge IP). */
APPROXIMATED_DNS_TARGET_IP: z.string().optional(),
/** Optional CNAME target customers can point to instead of A record. */
APPROXIMATED_DNS_TARGET_CNAME: z.string().optional(),
// S3-compatible storage (MinIO, AWS S3, or other S3-compatible backends)
OBJECT_STORAGE_ENDPOINT: z.string().optional(),
OBJECT_STORAGE_REGION: z.string().optional(),
OBJECT_STORAGE_ACCESS_KEY_ID: z.string().optional(),
OBJECT_STORAGE_SECRET_ACCESS_KEY: z.string().optional(),
OBJECT_STORAGE_FORCE_PATH_STYLE: z.string().optional(),
OBJECT_STORAGE_BUCKET_VIDEOS: z.string().optional(),
OBJECT_STORAGE_BUCKET_DOCUMENTS: z.string().optional(),
OBJECT_STORAGE_BUCKET_MEDIA: z.string().optional(),
/** Public URL base for media bucket (images, thumbnails). Required for image uploads. */
OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL: z.string().optional(),
/** Optional: endpoint for presigned URLs when different from internal (e.g. browser-reachable) */
OBJECT_STORAGE_PUBLIC_ENDPOINT: z.string().optional(),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
OPENAPI_URL: z.string().optional(),
PORT: z.string().optional(),
REDIS_URL: z.string().optional(),
PUBLIC_EMBED_BASE_URL: z.string().optional(),
PUBLIC_SERVER_URL: z.string().optional(),
SMTP_HOST: z.string().optional(),
SMTP_PASSWORD: z.string().optional(),
SMTP_PORT: z.string().optional(),
SMTP_SENDER: z.string().optional(),
SMTP_USER: z.string().optional(),
ZOHO_TOKEN: z.string().optional(),
TRUSTED_ORIGINS: z.string().optional(),
PRIVATE_SERVER_KEY: z.string().optional(),
PROJECT_ID_VERCEL: z.string().optional(),
TEAM_ID_VERCEL: z.string().optional(),
AUTH_BEARER_TOKEN: z.string().optional(),
UNSPLASH_API_KEY: z.string().optional(),
/** When 'true', instance runs in self-hosted mode: single org, auto-enroll as student, no org switching */
PUBLIC_IS_SELFHOSTED: z.string().optional(),
/** License key for enterprise features (SSO, token-auth, no-tracking, etc.) */
LICENSE_KEY: z.string().optional(),
/** Dashboard origin for invite/email links (e.g. https://app.yourdomain.com). When set, all email links point here instead of app.classroomio.com. Required for self-hosted. */
DASHBOARD_ORIGIN: z.string().optional(),
/**
* Comma-separated list of Better Auth user emails allowed to access the
* BullMQ dashboard at /admin/queues in production. Leave unset to keep the
* dashboard hidden everywhere except dev.
*/
QUEUE_DASHBOARD_ADMIN_EMAILS: z.string().optional(),
/**
* Bearer-token escape hatch for /admin/queues — useful for curl scripts or
* when SSO is unavailable. When set, requests with `Authorization: Bearer
* <token>` (or `?token=<token>`) bypass the email allowlist.
*/
QUEUE_DASHBOARD_TOKEN: z.string().optional(),
/** Optional Bearer token for Jina Reader (higher rate limits). */
JINA_API_KEY: z.string().optional(),
/** Max completed fetch_documentation_url calls per agent conversation (default 15). */
AGENT_MAX_FETCHES_PER_CONVERSATION: z
.string()
.optional()
.transform((v) => {
if (v === undefined || v === '') {
return 15;
}
const parsed = Number(v);
return Number.isFinite(parsed) && parsed >= 1 ? Math.trunc(parsed) : 15;
}),
/** Sentry error tracking (server-side). Leave SENTRY_DSN unset to disable. */
SENTRY_DSN: z.string().optional(),
SENTRY_ENVIRONMENT: z.string().optional(),
SENTRY_TRACES_SAMPLE_RATE: z.string().optional(),
/** Max fetch_documentation_url calls per org per UTC day (default 500). */
AGENT_MAX_FETCHES_PER_ORG_PER_DAY: z
.string()
.optional()
.transform((v) => {
if (v === undefined || v === '') {
return 500;
}
const parsed = Number(v);
return Number.isFinite(parsed) && parsed >= 1 ? Math.trunc(parsed) : 500;
})
});
export const env = envSchema.parse(process.env);
-136
View File
@@ -1,136 +0,0 @@
/**
* Object storage configuration for S3-compatible backends (Cloudflare R2, MinIO, AWS S3).
* Supports both generic OBJECT_STORAGE_* vars and backward-compatible CLOUDFLARE_* fallback.
*/
import { S3Client } from '@aws-sdk/client-s3';
import { env } from '@api/config/env';
const BUCKET_DEFAULTS = {
VIDEOS: 'videos',
DOCUMENTS: 'documents',
MEDIA: 'media'
} as const;
/** Whether we're using the generic S3-compatible config (MinIO, etc.) vs Cloudflare R2 fallback */
export function useS3CompatibleConfig(): boolean {
return Boolean(
env.OBJECT_STORAGE_ENDPOINT && env.OBJECT_STORAGE_ACCESS_KEY_ID && env.OBJECT_STORAGE_SECRET_ACCESS_KEY
);
}
function isCloudflareConfigured(): boolean {
return Boolean(env.CLOUDFLARE_ACCOUNT_ID && env.CLOUDFLARE_ACCESS_KEY && env.CLOUDFLARE_SECRET_ACCESS_KEY);
}
export interface StorageConfig {
endpoint: string;
region: string;
accessKeyId: string;
secretAccessKey: string;
forcePathStyle: boolean;
bucketVideos: string;
bucketDocuments: string;
bucketMedia: string;
mediaPublicBaseUrl: string | null;
presignUploadExpiresSeconds: number;
presignDownloadExpiresSeconds: number;
}
function resolveStorageConfig(): StorageConfig {
if (useS3CompatibleConfig()) {
const forcePathStyle =
env.OBJECT_STORAGE_FORCE_PATH_STYLE === 'true' || env.OBJECT_STORAGE_FORCE_PATH_STYLE === '1';
return {
endpoint: env.OBJECT_STORAGE_ENDPOINT!,
region: env.OBJECT_STORAGE_REGION || 'us-east-1',
accessKeyId: env.OBJECT_STORAGE_ACCESS_KEY_ID!,
secretAccessKey: env.OBJECT_STORAGE_SECRET_ACCESS_KEY!,
forcePathStyle,
bucketVideos: env.OBJECT_STORAGE_BUCKET_VIDEOS || BUCKET_DEFAULTS.VIDEOS,
bucketDocuments: env.OBJECT_STORAGE_BUCKET_DOCUMENTS || BUCKET_DEFAULTS.DOCUMENTS,
bucketMedia: env.OBJECT_STORAGE_BUCKET_MEDIA || BUCKET_DEFAULTS.MEDIA,
mediaPublicBaseUrl: env.OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL || null,
presignUploadExpiresSeconds: 60 * 60, // 1 hour
presignDownloadExpiresSeconds: 60 * 60 // 1 hour
};
}
if (isCloudflareConfigured()) {
return {
endpoint: `https://${env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`,
region: 'auto',
accessKeyId: env.CLOUDFLARE_ACCESS_KEY!,
secretAccessKey: env.CLOUDFLARE_SECRET_ACCESS_KEY!,
forcePathStyle: false,
bucketVideos: BUCKET_DEFAULTS.VIDEOS,
bucketDocuments: BUCKET_DEFAULTS.DOCUMENTS,
bucketMedia: BUCKET_DEFAULTS.MEDIA,
mediaPublicBaseUrl: env.CLOUDFLARE_IMAGE_BUCKET_DOMAIN || null,
presignUploadExpiresSeconds: 60 * 60,
presignDownloadExpiresSeconds: 60 * 60
};
}
throw new Error(
'Object storage not configured. Set either OBJECT_STORAGE_ENDPOINT, OBJECT_STORAGE_ACCESS_KEY_ID, OBJECT_STORAGE_SECRET_ACCESS_KEY (for MinIO/S3) or CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_ACCESS_KEY, CLOUDFLARE_SECRET_ACCESS_KEY (for Cloudflare R2).'
);
}
let cachedConfig: StorageConfig | null = null;
export function getStorageConfig(): StorageConfig {
if (!cachedConfig) {
cachedConfig = resolveStorageConfig();
if (useS3CompatibleConfig()) {
console.log('[storage] Using S3-compatible object storage');
} else {
console.log('[storage] Using Cloudflare R2 (fallback)');
}
}
return cachedConfig;
}
/** S3 client for API operations (upload, get, delete). Uses internal endpoint. */
let s3ClientInstance: S3Client | null = null;
export function getS3Client(): S3Client {
if (!s3ClientInstance) {
const config = getStorageConfig();
s3ClientInstance = new S3Client({
region: config.region,
endpoint: config.endpoint,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey
},
forcePathStyle: config.forcePathStyle
});
}
return s3ClientInstance;
}
/**
* S3 client for generating presigned URLs. Uses public endpoint when configured
* (e.g. when internal endpoint like minio:9000 is not reachable by browser).
*/
let presignClientInstance: S3Client | null = null;
export function getPresignS3Client(): S3Client {
if (!presignClientInstance) {
const config = getStorageConfig();
const endpoint = env.OBJECT_STORAGE_PUBLIC_ENDPOINT || config.endpoint;
presignClientInstance = new S3Client({
region: config.region,
endpoint,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey
},
forcePathStyle: config.forcePathStyle
});
}
return presignClientInstance;
}
export { BUCKET_DEFAULTS };
+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
});
}
@@ -1,342 +0,0 @@
import { z } from 'zod';
import { CoursePlanSchema } from '@cio/ai-assistant';
import {
LANDING_PAGE_COURSE_DESCRIPTION_PLAIN_HINT,
LANDING_PAGE_METADATA_DESCRIPTION_SECTION_HINT,
LANDING_PAGE_SECTION_HTML_AGENT_HINT
} from '@cio/ai-assistant/tools';
import { QUESTION_TYPE_REGISTRY } from '@cio/question-types';
import { ZExerciseSectionAfterBehavior } from '@cio/utils/validation/exercise';
import { ZCourseLandingPageUpdate, ZCourseLandingPageMetadataUpdate } from '@cio/utils/validation/course';
// courseId is NOT a parameter — it's injected from the authenticated request context.
// This prevents prompt injection from tricking the LLM into targeting another course.
export const emptyParam = z.object({});
export const lessonReadParam = z.object({ lessonId: z.string(), locale: z.string().default('en') });
export const exerciseReadParam = z.object({ exerciseId: z.string() });
export const createSectionParam = z.object({ title: z.string().min(1), order: z.number().int().min(0) });
export const updateSectionParam = z
.object({
sectionId: z.string(),
title: z.string().min(1).optional(),
order: z.number().int().min(0).optional()
})
.refine((data) => data.title !== undefined || data.order !== undefined, {
message: 'Provide at least one field to update'
});
export const createLessonParam = z.object({
sectionId: z.string(),
title: z.string().min(1),
order: z.number().int().min(0)
});
export const updateLessonParam = z
.object({
lessonId: z.string(),
title: z.string().min(1).optional(),
sectionId: z.string().optional(),
order: z.number().int().min(0).optional(),
lessonAt: z.string().optional(),
callUrl: z.string().optional(),
isUnlocked: z.boolean().optional(),
public: z.boolean().optional()
})
.refine(
(data) =>
data.title !== undefined ||
data.sectionId !== undefined ||
data.order !== undefined ||
data.lessonAt !== undefined ||
data.callUrl !== undefined ||
data.isUnlocked !== undefined ||
data.public !== undefined,
{
message: 'Provide at least one field to update'
}
);
export const updateContentParam = z.object({
lessonId: z.string(),
locale: z.string().default('en'),
content: z.string().min(1)
});
export const questionSchema = z.object({
question: z.string().min(1),
questionTypeId: z
.number()
.int()
.min(1)
.max(QUESTION_TYPE_REGISTRY.length)
.describe(
'Required. Use the numeric question type IDs from the teacher system prompt (Question Types). Omitting this field is invalid — set an explicit type on every question and vary types within each exercise.'
),
points: z.number().min(0).default(1),
order: z.number().int().min(0),
options: z.array(z.object({ label: z.string().min(1), isCorrect: z.boolean() }))
});
export const createExerciseParam = z.object({
lessonId: z.string().optional(),
sectionId: z.string().optional(),
title: z.string().min(1),
description: z.string().optional().describe('Optional short description shown to students above the questions.'),
order: z
.number()
.int()
.min(0)
.optional()
.describe(
'Display order within the section (0-based). Required when sectionId is provided. Lessons and exercises share the same order space within a section.'
),
questions: z.array(questionSchema)
});
export const updateExerciseParam = z
.object({
exerciseId: z.string().describe('The exercise to update. Must be a real UUID returned by a prior tool call.'),
title: z.string().min(1).optional().describe('New exercise title. Omit to keep unchanged.'),
description: z
.string()
.optional()
.describe('New short description shown to students. Pass an empty string to clear it.'),
lessonId: z.string().optional().describe('Link the exercise to a lesson in this course.'),
sectionId: z.string().optional().describe('Move the exercise to a section in this course.'),
order: z.number().int().min(0).optional().describe('New order within the section (0-based).'),
dueBy: z
.string()
.optional()
.describe('Due date in ISO 8601 format (e.g. 2026-05-01T23:59:00Z). Omit to keep unchanged.'),
isUnlocked: z.boolean().optional().describe('Whether the exercise is unlocked for students.'),
allowMultipleAttempts: z.boolean().optional().describe('Whether students can re-take the exercise.')
})
.refine(
(data) =>
data.title !== undefined ||
data.description !== undefined ||
data.lessonId !== undefined ||
data.sectionId !== undefined ||
data.order !== undefined ||
data.dueBy !== undefined ||
data.isUnlocked !== undefined ||
data.allowMultipleAttempts !== undefined,
{
message: 'Provide at least one field to update'
}
);
export const updateExerciseSectionParam = z
.object({
exerciseId: z
.string()
.describe('The exercise that contains this section. Must match the exercise you read with get_exercise_details.'),
exerciseSectionId: z
.string()
.describe('The exercise section id from get_exercise_details `sections[].id` — not a course section id.'),
title: z.string().min(1).optional().describe('New section heading shown above questions in this block.'),
description: z.string().optional().describe('Optional intro HTML for this block. Pass an empty string to clear it.')
})
.refine((data) => data.title !== undefined || data.description !== undefined, {
message: 'Provide at least title or description to update'
});
export const createExerciseSectionParam = z.object({
exerciseId: z.string().describe('The exercise that will contain this section.'),
title: z.string().min(1).describe('Section heading shown above the questions in this block.'),
description: z
.string()
.optional()
.describe('Optional intro HTML for this block. Pass an empty string to leave it blank.'),
order: z.number().int().min(0).describe('Display order within the exercise (0-based).'),
colorTheme: z.enum(['blue', 'green', 'amber', 'rose', 'violet', 'slate']).optional(),
afterBehavior: ZExerciseSectionAfterBehavior.optional()
});
export const addQuestionsParam = z.object({
exerciseId: z.string(),
exerciseSectionId: z
.string()
.uuid()
.optional()
.describe(
'In-exercise section id from get_exercise_details `sections[].id` where the new questions should appear (not a course outline section id).'
),
questions: z.array(questionSchema)
});
export const updateQuestionSettingsSchema = z
.object({
correctValue: z.union([z.number(), z.string(), z.boolean()]).optional(),
tolerance: z.number().min(0).optional(),
correctAnswers: z.array(z.string().min(1)).optional(),
template: z.string().min(1).optional(),
maxStars: z.number().int().min(1).optional(),
caseSensitive: z.boolean().optional(),
distractors: z.array(z.string().min(1)).optional()
})
.passthrough()
.refine((settings) => Object.keys(settings).length > 0, {
message: 'Provide at least one settings field'
})
.describe(
'Per-type correct-answer storage. NUMERIC: { correctValue: number, tolerance?: number }. STAR: { correctValue: number }. WORD_BANK: { correctAnswers: string[], template: string }.'
);
export const updateOptionSchema = z.object({
id: z.number().int().optional(),
label: z.string().min(1).optional(),
isCorrect: z.boolean().optional(),
settings: updateQuestionSettingsSchema.optional()
});
export const updateQuestionPatchSchema = z
.object({
id: z.number().int(),
question: z.string().min(1).optional(),
questionTypeId: z.number().int().min(1).max(QUESTION_TYPE_REGISTRY.length).optional(),
points: z.number().min(0).optional(),
order: z.number().int().min(0).optional(),
exerciseSectionId: z
.string()
.uuid()
.nullable()
.optional()
.describe(
'Move the question to this in-exercise block (get_exercise_details sections[].id). Use null to clear section assignment.'
),
settings: updateQuestionSettingsSchema.optional(),
options: z.array(updateOptionSchema).optional()
})
.refine(
(patch) =>
patch.question !== undefined ||
patch.questionTypeId !== undefined ||
patch.points !== undefined ||
patch.order !== undefined ||
patch.exerciseSectionId !== undefined ||
patch.settings !== undefined ||
patch.options !== undefined,
{
message: 'Provide at least one field to update besides id'
}
);
export const updateQuestionsParam = z.object({
exerciseId: z.string(),
questions: z.array(updateQuestionPatchSchema).min(1)
});
export const coursePlanParam = z.object({ plan: CoursePlanSchema });
const courseTemplateIdParam = z.enum(['product_101', 'product_onboarding', 'expert_on_x']);
const agentTemplateFormFieldBase = z.object({
id: z.string().min(1),
label: z.string().min(1),
description: z.string().optional(),
required: z.boolean().optional(),
placeholder: z.string().optional()
});
const agentTemplateFormFieldParam = z.discriminatedUnion('type', [
agentTemplateFormFieldBase.extend({ type: z.literal('text') }),
agentTemplateFormFieldBase.extend({ type: z.literal('textarea') }),
agentTemplateFormFieldBase.extend({ type: z.literal('url') }),
agentTemplateFormFieldBase.extend({
type: z.literal('select'),
options: z.array(z.object({ value: z.string().min(1), label: z.string().min(1) })).min(1)
})
]);
export const askTemplateQuestionsParam = z.object({
templateId: courseTemplateIdParam,
title: z.string().min(1),
description: z.string().optional(),
fields: z.array(agentTemplateFormFieldParam).min(1)
});
export const fetchDocumentationUrlParam = z.object({
url: z.string().url()
});
// Gemini's tool-schema validator only accepts string enums, so numeric/boolean
// `z.literal` values must be relaxed to plain types in the schemas the model sees.
export const agentLessonTabsOrder = z.array(
z.object({
id: z.number().int().min(1).max(4),
name: z.string()
})
);
export const agentLandingPageMetadataUpdate = ZCourseLandingPageMetadataUpdate.extend({
lessonTabsOrder: agentLessonTabsOrder.optional(),
requirements: z.string().optional().describe(LANDING_PAGE_SECTION_HTML_AGENT_HINT),
description: z.string().optional().describe(LANDING_PAGE_METADATA_DESCRIPTION_SECTION_HINT),
goals: z.string().optional().describe(LANDING_PAGE_SECTION_HTML_AGENT_HINT),
instructor: z
.object({
name: z.string().optional(),
role: z.string().optional(),
coursesNo: z.number().optional(),
description: z.string().optional().describe(LANDING_PAGE_SECTION_HTML_AGENT_HINT),
imgUrl: z.string().optional()
})
.optional()
});
export const updateCourseLandingPageParam = ZCourseLandingPageUpdate.extend({
title: z.string().min(1).optional().describe('Plain-text public course title (no HTML).'),
description: z.string().min(1).optional().describe(LANDING_PAGE_COURSE_DESCRIPTION_PLAIN_HINT),
overview: z.string().optional().describe(LANDING_PAGE_SECTION_HTML_AGENT_HINT),
generateImage: z
.boolean()
.optional()
.describe(
'Set true to auto-resolve a banner image from Unsplash. The server searches Unsplash using imageQuery if provided, otherwise the course title. Use this to fix a missing-banner blocker — do NOT ask the teacher to describe an image.'
),
imageQuery: z
.string()
.min(1)
.max(120)
.optional()
.describe('Optional Unsplash search query (1–120 chars). Omit to let the server use the course title.'),
metadata: agentLandingPageMetadataUpdate.optional()
}).refine(
(data) =>
data.title !== undefined ||
data.description !== undefined ||
data.overview !== undefined ||
data.cost !== undefined ||
data.currency !== undefined ||
data.imageUrl !== undefined ||
data.generateImage !== undefined ||
data.imageQuery !== undefined ||
data.metadata !== undefined,
{
message: 'Provide at least one landing-page field to update'
}
);
export const goLiveParam = z.object({
confirmPublish: z
.boolean()
.describe('Must be true. Only use this after the teacher explicitly asks to publish or go live.')
});
export const reorderContentParam = z.object({
sections: z
.array(
z.object({
id: z.string().min(1),
order: z.number().int().min(0)
})
)
.optional()
.describe('Reorder sections by setting new order values'),
items: z
.array(
z.object({
id: z.string().min(1),
type: z.enum(['LESSON', 'EXERCISE']),
order: z.number().int().min(0).optional().describe('New order within the section'),
sectionId: z.string().nullable().optional().describe('Move item to a different section')
})
)
.optional()
.describe('Reorder or move lessons/exercises')
});
-183
View File
@@ -1,183 +0,0 @@
import { AppError } from '@api/utils/errors';
import { getDocumentText } from '@api/services/agent/document';
import { redis } from '@api/utils/redis/redis';
import { getCourseSectionBinding, getExerciseCourseBinding, getLessonCourseBinding } from '@cio/db/queries/agent';
import { z } from 'zod';
import { CoursePlanFieldsSchema, type CourseTemplateId } from '@cio/ai-assistant';
type ResourceOwnershipRow = {
courseId: string | null;
title?: string | null;
};
function buildResourceOwnershipError(params: {
resourceType: 'Lesson' | 'Exercise' | 'Section';
resourceId: string;
courseId: string;
resource?: ResourceOwnershipRow;
}) {
const { resourceType, resourceId, resource } = params;
if (!resource) {
return new AppError(
`${resourceType} ${resourceId} does not exist in this course. The ID may have been hallucinated — call get_course_structure to fetch real IDs and retry.`,
'RESOURCE_NOT_IN_COURSE',
403
);
}
const titleSuffix = resource.title ? ` (${resource.title})` : '';
return new AppError(
`${resourceType} ${resourceId}${titleSuffix} belongs to a different course. Call get_course_structure to fetch IDs for the current course and retry.`,
'RESOURCE_NOT_IN_COURSE',
403
);
}
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function assertValidUuid(
resourceType: 'Lesson' | 'Exercise' | 'Section' | 'ExerciseSection',
value: string
): void {
if (!UUID_REGEX.test(value)) {
throw new AppError(
`${resourceType} id "${value}" is not a valid UUID. Call get_course_structure to fetch real IDs and try again — never invent or guess UUIDs.`,
'INVALID_RESOURCE_ID',
400
);
}
}
export async function verifyLessonBelongsToCourse(lessonId: string, courseId: string): Promise<void> {
assertValidUuid('Lesson', lessonId);
const lesson = await getLessonCourseBinding(lessonId);
if (!lesson || lesson.courseId !== courseId) {
throw buildResourceOwnershipError({
resourceType: 'Lesson',
resourceId: lessonId,
courseId,
resource: lesson
});
}
}
export async function verifyExerciseBelongsToCourse(exerciseId: string, courseId: string): Promise<void> {
assertValidUuid('Exercise', exerciseId);
const exercise = await getExerciseCourseBinding(exerciseId);
if (!exercise || exercise.courseId !== courseId) {
throw buildResourceOwnershipError({
resourceType: 'Exercise',
resourceId: exerciseId,
courseId,
resource: exercise
});
}
}
export async function verifySectionBelongsToCourse(sectionId: string, courseId: string): Promise<void> {
assertValidUuid('Section', sectionId);
const section = await getCourseSectionBinding(sectionId);
if (!section || section.courseId !== courseId) {
throw buildResourceOwnershipError({
resourceType: 'Section',
resourceId: sectionId,
courseId,
resource: section
});
}
}
type AttachedMessage = { metadata?: { attachment?: { documentId?: string } } };
export function collectDocumentIds(messages: unknown[], currentDocumentId?: string): string[] {
const ids = new Set<string>();
for (const msg of messages as AttachedMessage[]) {
const id = msg?.metadata?.attachment?.documentId;
if (id) ids.add(id);
}
if (currentDocumentId) ids.add(currentDocumentId);
return Array.from(ids);
}
export async function loadDocumentsText(documentIds: string[], userId: string): Promise<string | undefined> {
const loaded = await Promise.all(
documentIds.map(async (id) => {
const text = await getDocumentText(id, userId, redis);
return text ? { id, text } : null;
})
);
const sections = loaded
.filter((d): d is { id: string; text: string } => d !== null)
.map((d, i) => `--- Document ${i + 1} (id: ${d.id}) ---\n${d.text}`);
return sections.length > 0 ? sections.join('\n\n') : undefined;
}
type PlanMetadataMessage = {
role?: string;
metadata?: {
plan?: {
action?: string;
payload?: unknown;
};
};
};
export function getLatestImplementationPlan(messages: unknown[]): z.infer<typeof CoursePlanFieldsSchema> | undefined {
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index] as PlanMetadataMessage;
if (message?.role !== 'user') {
continue;
}
if (message?.metadata?.plan?.action !== 'implement_course_plan') {
continue;
}
const parsedPlan = CoursePlanFieldsSchema.safeParse(message.metadata.plan.payload);
if (parsedPlan.success) {
return parsedPlan.data;
}
}
return undefined;
}
const COURSE_TEMPLATE_ID_SET = new Set<CourseTemplateId>(['product_101', 'product_onboarding', 'expert_on_x']);
export function getActiveCourseTemplateId(messages: unknown[]): CourseTemplateId | undefined {
for (const message of messages) {
const candidate = message as {
role?: string;
metadata?: { template?: { id?: string } };
};
if (candidate.role !== 'user') {
continue;
}
const id = candidate.metadata?.template?.id;
if (id && COURSE_TEMPLATE_ID_SET.has(id as CourseTemplateId)) {
return id as CourseTemplateId;
}
}
return undefined;
}
+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);
-710
View File
@@ -1,710 +0,0 @@
import { tool } from 'ai';
import { CoursePlanSchema } from '@cio/ai-assistant';
import { AppError } from '@api/utils/errors';
import { trackAgentEvent, AgentEvent } from '@api/utils/tinybird';
import { getCourseContentItems } from '@cio/db/queries/course/content';
import { getExerciseSectionsByExerciseId } from '@cio/db/queries/exercise';
import type { TCourseLandingPageUpdate } from '@cio/utils/validation/course';
import { listCourseSections, createCourseSection, updateCourseSectionService } from '@api/services/course/section';
import { createLesson, getLesson, updateLessonService } from '@api/services/lesson/lesson';
import { upsertLessonLanguageService } from '@api/services/lesson-language';
import {
createExercise,
getExercise,
createExerciseSectionService,
updateExerciseService,
updateExerciseSectionMetadataService
} from '@api/services/exercise/exercise';
import { reorderCourseContent } from '@api/services/course/content';
import { normalizeAgentLessonContent } from '@api/services/agent/lesson-content';
import { buildUpdatedQuestions } from '@api/services/agent/question-update';
import { updateCourseLandingPageService } from '@api/services/course/landing-page';
import { getCourseGoLiveReadiness, publishCourseWhenReady } from '@api/services/course/go-live-readiness';
import {
assertValidUuid,
verifyExerciseBelongsToCourse,
verifyLessonBelongsToCourse,
verifySectionBelongsToCourse
} from '@api/services/agent/chat-context';
import {
addQuestionsParam,
askTemplateQuestionsParam,
coursePlanParam,
createExerciseParam,
createExerciseSectionParam,
createLessonParam,
createSectionParam,
emptyParam,
exerciseReadParam,
fetchDocumentationUrlParam,
goLiveParam,
lessonReadParam,
reorderContentParam,
updateContentParam,
updateCourseLandingPageParam,
updateExerciseParam,
updateExerciseSectionParam,
updateLessonParam,
updateQuestionsParam,
updateSectionParam
} from '@api/services/agent/agent-tool-schemas';
import { fetchDocumentationUrl } from '@api/services/agent/fetch-url';
function summarizeAgentDebugValue(value: unknown, depth = 0): unknown {
if (value == null) return value;
if (typeof value === 'string') {
return value.length > 200 ? `${value.slice(0, 200)}… (${value.length} chars)` : value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return value;
}
if (Array.isArray(value)) {
if (depth >= 2) {
return `[Array(${value.length})]`;
}
return {
count: value.length,
items: value.slice(0, 3).map((item) => summarizeAgentDebugValue(item, depth + 1))
};
}
if (typeof value === 'object') {
if (depth >= 2) {
return '[Object]';
}
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, nestedValue]) => [
key,
summarizeAgentDebugValue(nestedValue, depth + 1)
])
);
}
return String(value);
}
function logAgentToolDebug(
phase: 'start' | 'success' | 'error',
toolName: string,
details: { courseId: string; userId: string; args?: unknown; result?: unknown; error?: unknown }
) {
const base = `[agent-tool:${phase}] ${toolName}`;
if (phase === 'error') {
console.error(base, {
courseId: details.courseId,
userId: details.userId,
args: summarizeAgentDebugValue(details.args),
error:
details.error instanceof Error
? {
message: details.error.message,
stack: details.error.stack
}
: summarizeAgentDebugValue(details.error)
});
return;
}
console.info(base, {
courseId: details.courseId,
userId: details.userId,
args: summarizeAgentDebugValue(details.args),
result: summarizeAgentDebugValue(details.result)
});
}
function sanitizeToolError(toolName: string, error: unknown): Error {
if (error instanceof AppError) {
return error;
}
const raw = error instanceof Error ? error.message : String(error);
const looksLikeSqlLeak =
/^Failed query:/i.test(raw) ||
/\bparams:\s/i.test(raw) ||
/\binvalid input syntax for type uuid\b/i.test(raw) ||
/\bselect\s+.+\bfrom\s+/i.test(raw);
if (looksLikeSqlLeak) {
return new Error(
`${toolName} failed because of an invalid or unknown ID. Call get_course_structure to fetch the current IDs and retry — never invent UUIDs.`
);
}
return error instanceof Error ? error : new Error(raw);
}
async function executeAgentTool<TArgs, TResult>(
toolName: string,
params: { orgId: string; userId: string; courseId: string; args?: TArgs },
execute: () => Promise<TResult>
): Promise<TResult> {
trackAgentEvent(AgentEvent.TOOL_CALLED, {
orgId: params.orgId,
userId: params.userId,
courseId: params.courseId,
toolName
});
logAgentToolDebug('start', toolName, {
courseId: params.courseId,
userId: params.userId,
args: params.args
});
try {
const result = await execute();
trackAgentEvent(AgentEvent.TOOL_COMPLETED, {
orgId: params.orgId,
userId: params.userId,
courseId: params.courseId,
toolName,
success: true
});
logAgentToolDebug('success', toolName, {
courseId: params.courseId,
userId: params.userId,
args: params.args,
result
});
return result;
} catch (error) {
logAgentToolDebug('error', toolName, {
courseId: params.courseId,
userId: params.userId,
args: params.args,
error
});
throw sanitizeToolError(toolName, error);
}
}
export function buildAgentTools(
orgId: string,
userId: string,
courseId: string,
priorMessages: unknown[],
_options?: { isOrgOnPaidPlan?: boolean }
) {
return {
get_course_structure: tool({
description:
'Get the full course structure including sections, lessons, and exercises as a tree. The courseId is automatically set — do not pass it.',
inputSchema: emptyParam,
execute: async () => {
return executeAgentTool('get_course_structure', { orgId, userId, courseId }, async () => {
const [items, sections] = await Promise.all([getCourseContentItems(courseId), listCourseSections(courseId)]);
return { sections, items };
});
}
}),
get_lesson_content: tool({
description: 'Get the HTML content of a specific lesson in this course.',
inputSchema: lessonReadParam,
execute: async (args) => {
return executeAgentTool('get_lesson_content', { orgId, userId, courseId, args }, async () => {
await verifyLessonBelongsToCourse(args.lessonId, courseId);
const lesson = await getLesson(args.lessonId);
const lessonWithLangs = lesson as {
id: string;
title: string;
lessonLanguages?: Array<{ locale: string; content: string | null }>;
};
const langContent = lessonWithLangs.lessonLanguages?.find((ll) => ll.locale === args.locale);
return { id: lesson.id, title: lesson.title, content: langContent?.content || null, locale: args.locale };
});
}
}),
get_exercise_details: tool({
description:
'Get an exercise with sections, questions, and answer options from this course. Each sections entry has an id — use it with update_exercise_section to change that question block title or description.',
inputSchema: exerciseReadParam,
execute: async (args) => {
return executeAgentTool('get_exercise_details', { orgId, userId, courseId, args }, async () => {
await verifyExerciseBelongsToCourse(args.exerciseId, courseId);
return getExercise(args.exerciseId);
});
}
}),
create_section: tool({
description: 'Create a new section in this course.',
inputSchema: createSectionParam,
execute: async (args) => {
return executeAgentTool('create_section', { orgId, userId, courseId, args }, async () => {
const section = await createCourseSection(courseId, { title: args.title, courseId, order: args.order });
return { id: section.id, title: section.title, order: section.order };
});
}
}),
update_section: tool({
description:
'Update metadata for an existing section in this course. Use this to rename a section or change its order instead of creating a new section.',
inputSchema: updateSectionParam,
execute: async (args) => {
return executeAgentTool('update_section', { orgId, userId, courseId, args }, async () => {
await verifySectionBelongsToCourse(args.sectionId, courseId);
const section = await updateCourseSectionService(args.sectionId, {
...(args.title !== undefined ? { title: args.title } : {}),
...(args.order !== undefined ? { order: args.order } : {})
});
return { id: section.id, title: section.title, updated: true };
});
}
}),
create_lesson: tool({
description:
'Create a new lesson within a section of this course. Use update_lesson_content after to add content.',
inputSchema: createLessonParam,
execute: async (args) => {
return executeAgentTool('create_lesson', { orgId, userId, courseId, args }, async () => {
await verifySectionBelongsToCourse(args.sectionId, courseId);
const lesson = await createLesson(courseId, {
title: args.title,
courseId,
sectionId: args.sectionId,
order: args.order
});
return { id: lesson.id, title: lesson.title, order: lesson.order };
});
}
}),
update_lesson: tool({
description:
'Update metadata for an existing lesson in this course. Use this to rename, move, reorder, schedule, or change visibility/unlock settings instead of creating a new lesson.',
inputSchema: updateLessonParam,
execute: async (args) => {
return executeAgentTool('update_lesson', { orgId, userId, courseId, args }, async () => {
if (args.sectionId) {
await verifySectionBelongsToCourse(args.sectionId, courseId);
}
await verifyLessonBelongsToCourse(args.lessonId, courseId);
const lesson = await updateLessonService(args.lessonId, {
...(args.title !== undefined ? { title: args.title } : {}),
...(args.sectionId !== undefined ? { sectionId: args.sectionId } : {}),
...(args.order !== undefined ? { order: args.order } : {}),
...(args.lessonAt !== undefined ? { lessonAt: args.lessonAt } : {}),
...(args.callUrl !== undefined ? { callUrl: args.callUrl } : {}),
...(args.isUnlocked !== undefined ? { isUnlocked: args.isUnlocked } : {}),
...(args.public !== undefined ? { public: args.public } : {})
});
return { id: lesson.id, title: lesson.title, updated: true };
});
}
}),
update_lesson_content: tool({
description:
'Update the text content of a lesson in this course. Replaces full lesson HTML for the given locale. For lesson HTML, put only the lesson body in the content. Do not include the lesson title. Do not use h1 or h2 anywhere in lesson HTML. Start headings at h3 because that is the highest heading level allowed in lesson content.',
inputSchema: updateContentParam,
execute: async (args) => {
return executeAgentTool('update_lesson_content', { orgId, userId, courseId, args }, async () => {
await verifyLessonBelongsToCourse(args.lessonId, courseId);
const lesson = await getLesson(args.lessonId);
const normalizedContent = normalizeAgentLessonContent(args.content, lesson.title);
await upsertLessonLanguageService(args.lessonId, {
locale: args.locale as 'en',
content: normalizedContent
});
return {
lessonId: args.lessonId,
lessonTitle: lesson.title,
locale: args.locale,
contentLength: normalizedContent.length,
updated: true
};
});
}
}),
create_exercise: tool({
description: 'Create a new exercise with questions and answer options in this course.',
inputSchema: createExerciseParam,
execute: async (args) => {
return executeAgentTool('create_exercise', { orgId, userId, courseId, args }, async () => {
if (args.lessonId) {
await verifyLessonBelongsToCourse(args.lessonId, courseId);
}
if (args.sectionId) {
await verifySectionBelongsToCourse(args.sectionId, courseId);
}
const exercise = await createExercise({
title: args.title,
description: args.description,
courseId,
lessonId: args.lessonId,
sectionId: args.sectionId,
order: args.order,
questions: args.questions.map((q, i) => ({
question: q.question,
questionTypeId: q.questionTypeId,
points: q.points,
order: q.order ?? i,
options: q.options.map((o) => ({ label: o.label, isCorrect: o.isCorrect }))
}))
});
return { id: exercise.id, title: exercise.title, questionCount: args.questions.length };
});
}
}),
update_exercise: tool({
description:
"Update an existing exercise's metadata (title, description, linked lesson, section, order, due date, lock state, allow-multiple-attempts). Use this for editing the exercise itself — not for changing its questions. To edit questions, use update_questions; to add questions, use add_questions.",
inputSchema: updateExerciseParam,
execute: async (args) => {
return executeAgentTool('update_exercise', { orgId, userId, courseId, args }, async () => {
await verifyExerciseBelongsToCourse(args.exerciseId, courseId);
if (args.lessonId) {
await verifyLessonBelongsToCourse(args.lessonId, courseId);
}
if (args.sectionId) {
await verifySectionBelongsToCourse(args.sectionId, courseId);
}
const updated = await updateExerciseService(args.exerciseId, {
title: args.title,
description: args.description,
lessonId: args.lessonId,
sectionId: args.sectionId,
order: args.order,
dueBy: args.dueBy,
isUnlocked: args.isUnlocked,
allowMultipleAttempts: args.allowMultipleAttempts
});
return {
id: updated.id,
title: updated.title,
description: updated.description ?? null,
dueBy: updated.dueBy ?? null,
updated: true
};
});
}
}),
update_exercise_section: tool({
description:
'Update the title and/or description of one section inside an exercise (question groups). This is not the same as update_section, which edits course outline sections. Always use ids from get_exercise_details `sections` for this tool.',
inputSchema: updateExerciseSectionParam,
execute: async (args) => {
return executeAgentTool('update_exercise_section', { orgId, userId, courseId, args }, async () => {
assertValidUuid('Exercise', args.exerciseId);
assertValidUuid('ExerciseSection', args.exerciseSectionId);
await verifyExerciseBelongsToCourse(args.exerciseId, courseId);
const updated = await updateExerciseSectionMetadataService(args.exerciseId, args.exerciseSectionId, {
...(args.title !== undefined ? { title: args.title } : {}),
...(args.description !== undefined ? { description: args.description } : {})
});
return { ...updated, updated: true };
});
}
}),
create_exercise_section: tool({
description:
'Create a new section inside an existing exercise. Use this to add a question block before adding or moving questions into it.',
inputSchema: createExerciseSectionParam,
execute: async (args) => {
return executeAgentTool('create_exercise_section', { orgId, userId, courseId, args }, async () => {
assertValidUuid('Exercise', args.exerciseId);
await verifyExerciseBelongsToCourse(args.exerciseId, courseId);
const created = await createExerciseSectionService(args.exerciseId, {
title: args.title,
description: args.description,
order: args.order,
colorTheme: args.colorTheme,
afterBehavior: args.afterBehavior
});
return { ...created, created: true };
});
}
}),
add_questions: tool({
description:
'Add questions to an existing exercise in this course. When get_exercise_details lists in-exercise sections, pass exerciseSectionId so new questions are added to the correct block.',
inputSchema: addQuestionsParam,
execute: async (args) => {
return executeAgentTool('add_questions', { orgId, userId, courseId, args }, async () => {
await verifyExerciseBelongsToCourse(args.exerciseId, courseId);
if (args.exerciseSectionId !== undefined) {
assertValidUuid('ExerciseSection', args.exerciseSectionId);
const sections = await getExerciseSectionsByExerciseId(args.exerciseId);
const belongs = sections.some((section) => section.id === args.exerciseSectionId);
if (!belongs) {
throw new AppError(
'That exercise section was not found on this exercise. Call get_exercise_details and use a section id from the sections array.',
'VALIDATION_ERROR',
404
);
}
}
const existingExercise = await getExercise(args.exerciseId);
const existingQuestions = existingExercise.questions || [];
const nextOrder = existingQuestions.length;
const newQuestions = args.questions.map((q, i) => ({
question: q.question,
questionTypeId: q.questionTypeId,
points: q.points,
order: q.order ?? nextOrder + i,
...(args.exerciseSectionId !== undefined ? { exerciseSectionId: args.exerciseSectionId } : {}),
options: q.options.map((o) => ({ label: o.label, isCorrect: o.isCorrect }))
}));
const allQuestions = [
...existingQuestions.map((eq) => ({
id: Number(eq.id),
question: eq.title,
questionTypeId: eq.questionTypeId,
points: eq.points,
order: eq.order,
options: eq.options.map((o) => ({ id: Number(o.id), label: o.label || '', isCorrect: o.isCorrect }))
})),
...newQuestions
];
await updateExerciseService(args.exerciseId, { questions: allQuestions });
return {
exerciseId: args.exerciseId,
exerciseTitle: existingExercise.title,
addedCount: args.questions.length,
totalCount: allQuestions.length
};
});
}
}),
update_questions: tool({
description:
'Update existing questions in an exercise. Pass only fields you want to change; `id` identifies the question. Optional `exerciseSectionId` moves the question to another in-exercise block (use get_exercise_details section ids), or null to unassign. For NUMERIC, the correct answer is `settings.correctValue` (number) — do NOT add options to NUMERIC questions. For STAR use `settings.correctValue`. For WORD_BANK use `settings.correctAnswers` and `settings.template`. RADIO/CHECKBOX/TRUE_FALSE use `options[].isCorrect` (include option `id` to edit, omit `id` to add). `settings` is shallow-merged with existing settings. Omit `options` entirely to leave existing options untouched.',
inputSchema: updateQuestionsParam,
execute: async (args) => {
return executeAgentTool('update_questions', { orgId, userId, courseId, args }, async () => {
await verifyExerciseBelongsToCourse(args.exerciseId, courseId);
const sectionIdsInPatches = new Set<string>();
for (const patch of args.questions) {
if (patch.exerciseSectionId === undefined || patch.exerciseSectionId === null) {
continue;
}
assertValidUuid('ExerciseSection', patch.exerciseSectionId);
sectionIdsInPatches.add(patch.exerciseSectionId);
}
if (sectionIdsInPatches.size > 0) {
const sections = await getExerciseSectionsByExerciseId(args.exerciseId);
const validIds = new Set(sections.map((section) => section.id));
for (const sectionId of sectionIdsInPatches) {
if (!validIds.has(sectionId)) {
throw new AppError(
'That exercise section was not found on this exercise. Call get_exercise_details and use a section id from the sections array.',
'VALIDATION_ERROR',
404
);
}
}
}
const existing = await getExercise(args.exerciseId);
const merged = buildUpdatedQuestions(
(existing.questions || []).map((q) => ({
id: q.id,
title: q.title,
questionTypeId: q.questionTypeId,
points: q.points,
order: q.order,
exerciseSectionId: q.exerciseSectionId,
settings: q.settings,
options: q.options
})),
args.questions,
args.exerciseId
);
await updateExerciseService(args.exerciseId, { questions: merged });
return {
exerciseId: args.exerciseId,
exerciseTitle: existing.title,
updatedCount: merged.length
};
});
}
}),
reorder_content: tool({
description:
'Reorder sections, lessons, or exercises in this course. Can change the order of sections, change the order of lessons/exercises within a section, or move items between sections. Use get_course_structure first to see current order, and copy item IDs exactly from that response. Never rewrite or guess UUIDs.',
inputSchema: reorderContentParam,
execute: async (args) => {
return executeAgentTool('reorder_content', { orgId, userId, courseId, args }, async () => {
if (args.sections) {
for (const section of args.sections) {
await verifySectionBelongsToCourse(section.id, courseId);
}
}
if (args.items) {
for (const item of args.items) {
if (item.type === 'LESSON') {
await verifyLessonBelongsToCourse(item.id, courseId);
} else {
await verifyExerciseBelongsToCourse(item.id, courseId);
}
if (item.sectionId) {
await verifySectionBelongsToCourse(item.sectionId, courseId);
}
}
}
return reorderCourseContent(courseId, {
sections: args.sections,
items: args.items
});
});
}
}),
update_course_landing_page: tool({
description:
'Update course-level landing page fields for this course (public title, course description, overview, goals, requirements, the Description section after Requirements, instructor bio, pricing, banner image). The top-level course description field is plain text only—no HTML. All other narrative sections (overview, metadata goals/requirements, metadata description for the block after Requirements, instructor description, etc.) are HTML: paragraphs, lists, line breaks, bold, and italic only—never heading tags (h1–h6) because the UI shows section titles. Title is plain text. The courseId is automatically set — do not pass it.',
inputSchema: updateCourseLandingPageParam,
execute: async (args) => {
return executeAgentTool('update_course_landing_page', { orgId, userId, courseId, args }, async () => {
const result = await updateCourseLandingPageService(courseId, args as TCourseLandingPageUpdate);
return {
courseId: result.course.id,
title: result.course.title,
description: result.course.description,
courseUrl: result.courseUrl,
bannerImageUrl: result.bannerImageUrl,
updated: true
};
});
}
}),
check_course_go_live_readiness: tool({
description:
'Check whether this course is ready to go live. Returns blockers, warnings, suggested fixes, and the public course URL if available. This tool does not publish the course.',
inputSchema: emptyParam,
execute: async () => {
return executeAgentTool('check_course_go_live_readiness', { orgId, userId, courseId }, async () => {
return getCourseGoLiveReadiness(courseId);
});
}
}),
go_live_course: tool({
description:
'Publish this course only after the teacher explicitly asks to go live. Runs the readiness checklist first, generates a slug when needed, and fails with blockers if the course is not ready.',
inputSchema: goLiveParam,
execute: async (args) => {
return executeAgentTool('go_live_course', { orgId, userId, courseId, args }, async () => {
const result = await publishCourseWhenReady(courseId);
return {
courseId: result.course.id,
title: result.course.title,
slug: result.course.slug,
isPublished: result.course.isPublished,
readiness: result.readiness
};
});
}
}),
generate_course_plan: tool({
description:
'Generate a structured course plan with sections and lessons. Always use this when asked to design or plan a course.',
inputSchema: coursePlanParam,
execute: async (args) => {
return executeAgentTool('generate_course_plan', { orgId, userId, courseId, args }, async () => {
const envelope = coursePlanParam.parse(args);
const plan = CoursePlanSchema.parse(envelope.plan);
const sectionCount = plan.sections.length;
const itemCount = plan.sections.reduce((sum, section) => sum + section.items.length, 0);
trackAgentEvent(AgentEvent.PLAN_GENERATED, { orgId, userId, courseId, sectionCount, itemCount });
return plan;
});
}
}),
ask_template_questions: tool({
description:
'Render a structured questionnaire card to the teacher. Pause until the teacher submits via metadata.template.action submit_template_answers or skips via skip_template_form.',
inputSchema: askTemplateQuestionsParam,
execute: async (args) => {
return executeAgentTool(
'ask_template_questions',
{ orgId, userId, courseId, args },
async () =>
({
awaiting_user: true as const,
templateId: args.templateId,
title: args.title,
description: args.description,
fields: args.fields
}) as const
);
}
}),
fetch_documentation_url: tool({
description:
'Fetch a public documentation URL via Jina Reader. Returns markdown wrapped as untrusted external content plus same-origin links for follow-up fetches.',
inputSchema: fetchDocumentationUrlParam,
execute: async (args) => {
return executeAgentTool('fetch_documentation_url', { orgId, userId, courseId, args }, async () => {
return fetchDocumentationUrl({
url: args.url,
orgId,
courseId,
priorMessages
});
});
}
})
};
}
+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);
@@ -1,31 +0,0 @@
const MAX_MESSAGES = 20;
const KEEP_RECENT = 16;
const MAX_TOOL_OUTPUT_CHARS = 2000;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function trimToolOutputs(messages: any[]): any[] {
return messages.map((message) => {
if (!Array.isArray(message.parts)) return message;
const trimmedParts = message.parts.map((part: any) => {
if (part.state !== 'output-available' || part.output == null) return part;
const serialized = typeof part.output === 'string' ? part.output : JSON.stringify(part.output);
if (serialized.length <= MAX_TOOL_OUTPUT_CHARS) return part;
const truncated = serialized.slice(0, MAX_TOOL_OUTPUT_CHARS) + '…[truncated]';
return { ...part, output: truncated };
});
if (trimmedParts === message.parts) return message;
return { ...message, parts: trimmedParts };
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function trimMessageHistory(messages: any[]): any[] {
const countTrimmed = messages.length <= MAX_MESSAGES ? messages : [messages[0], ...messages.slice(-KEEP_RECENT)];
return trimToolOutputs(countTrimmed);
}
-231
View File
@@ -1,231 +0,0 @@
import { nanoid } from 'nanoid';
import { AppError } from '@api/utils/errors';
import {
MAX_DOCUMENT_TEXT_LENGTH,
MAX_AGENT_DOCUMENT_SIZE,
DOCUMENT_REDIS_TTL,
SUPPORTED_DOCUMENT_TYPES
} from '@cio/ai-assistant';
import type { DocumentUploadResult } from '@cio/ai-assistant';
import { agentDocumentKey } from '@api/utils/redis/key-generators';
import { trackAgentEvent, AgentEvent } from '@api/utils/tinybird';
import type { RedisClient } from '@api/utils/redis/redis';
import { createChatDocument, getChatDocument } from '@cio/db/queries/agent';
import { generateFileKey } from '@api/utils/upload';
import { uploadToS3 } from '@api/utils/s3';
import { getStorageConfig } from '@api/config/storage';
import { createAssetFromUploadService } from '@api/services/assets/assets';
/**
* Parse an uploaded document, store extracted text in Redis (hot cache) and Postgres
* (durable, scoped to a conversation). Supports PDF, DOCX, and PPTX files.
*/
export async function parseAndStoreDocument(
file: File,
orgId: string,
userId: string,
courseId: string,
conversationId: string,
redis: RedisClient
): Promise<DocumentUploadResult> {
const mimeType = file.type;
if (!SUPPORTED_DOCUMENT_TYPES.includes(mimeType as (typeof SUPPORTED_DOCUMENT_TYPES)[number])) {
throw new AppError('Unsupported file type. Allowed: PDF, DOCX, PPTX', 'UNSUPPORTED_FILE_TYPE', 415);
}
if (file.size > MAX_AGENT_DOCUMENT_SIZE) {
throw new AppError(
`File too large. Maximum size is ${MAX_AGENT_DOCUMENT_SIZE / (1024 * 1024)}MB`,
'FILE_TOO_LARGE',
413
);
}
const buffer = Buffer.from(await file.arrayBuffer());
let extractedText: string;
let pageCount: number | null = null;
switch (mimeType) {
case 'application/pdf':
({ text: extractedText, pageCount } = await extractPdfText(buffer));
break;
case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
extractedText = await extractDocxText(buffer);
break;
case 'application/vnd.openxmlformats-officedocument.presentationml.presentation':
({ text: extractedText, pageCount } = await extractPptxText(buffer));
break;
default:
throw new AppError('Unsupported file type', 'UNSUPPORTED_FILE_TYPE', 415);
}
const truncated = extractedText.length > MAX_DOCUMENT_TEXT_LENGTH;
if (truncated) {
extractedText = extractedText.slice(0, MAX_DOCUMENT_TEXT_LENGTH);
}
const wordCount = extractedText.split(/\s+/).filter(Boolean).length;
const textPreview = extractedText.slice(0, 200);
const documentId = nanoid();
// Persist the original file to S3 and register it as an asset so it shows
// up in the org's asset manager — same pipeline lessons use.
const storageKey = generateFileKey(file.name);
const storageConfig = getStorageConfig();
const uploadResult = await uploadToS3({
Bucket: storageConfig.bucketDocuments,
Key: storageKey,
Body: buffer,
ContentType: mimeType
});
if (!uploadResult.success) {
throw new AppError(
`Failed to upload document to storage: ${uploadResult.error ?? 'unknown error'}`,
'DOCUMENT_STORAGE_FAILED',
500
);
}
const asset = await createAssetFromUploadService(orgId, userId, {
kind: 'document',
provider: 'upload',
storageProvider: 's3',
storageKey,
byteSize: file.size,
mimeType,
title: file.name,
isExternal: false,
metadata: { source: 'ai_chat', conversationId }
});
await redis.set(
agentDocumentKey(documentId),
JSON.stringify({
text: extractedText,
fileName: file.name,
mimeType,
userId,
uploadedAt: new Date().toISOString()
}),
{ EX: DOCUMENT_REDIS_TTL }
);
await createChatDocument({
id: documentId,
conversationId,
courseId,
userId,
assetId: asset.id,
fileName: file.name,
mimeType,
text: extractedText,
wordCount,
pageCount
});
trackAgentEvent(AgentEvent.DOCUMENT_UPLOADED, {
orgId,
userId,
courseId,
mimeType,
fileSize: file.size,
wordCount,
truncated
});
return {
documentId,
fileName: file.name,
mimeType,
pageCount,
wordCount,
textPreview,
truncated
};
}
/**
* Retrieve stored document text. Tries the Redis hot cache first; on miss
* falls back to Postgres and rehydrates Redis for next time.
*/
export async function getDocumentText(documentId: string, userId: string, redis: RedisClient): Promise<string | null> {
const raw = await redis.get(agentDocumentKey(documentId));
if (raw) {
const parsed = JSON.parse(raw) as { text: string; userId?: string };
if (parsed.userId && parsed.userId !== userId) return null;
return parsed.text;
}
const record = await getChatDocument(documentId, userId);
if (!record) return null;
await redis.set(
agentDocumentKey(documentId),
JSON.stringify({
text: record.text,
fileName: record.fileName,
mimeType: record.mimeType,
userId: record.userId,
uploadedAt: record.createdAt
}),
{ EX: DOCUMENT_REDIS_TTL }
);
return record.text;
}
// ─── Extraction Helpers ──────────────────────────────────────────────────────
async function extractPdfText(buffer: Buffer): Promise<{ text: string; pageCount: number }> {
const pdfParse = (await import('pdf-parse')).default;
const result = await pdfParse(buffer);
return { text: result.text, pageCount: result.numpages };
}
async function extractDocxText(buffer: Buffer): Promise<string> {
const mammoth = await import('mammoth');
const result = await mammoth.extractRawText({ buffer });
return result.value;
}
async function extractPptxText(buffer: Buffer): Promise<{ text: string; pageCount: number }> {
// pptx-parser may not be available yet — use a simpler approach
// For now, try to use officegen or a basic XML extraction
try {
const JSZip = (await import('jszip')).default;
const zip = await JSZip.loadAsync(buffer);
const slideTexts: string[] = [];
const slideFiles = Object.keys(zip.files)
.filter((name) => name.match(/^ppt\/slides\/slide\d+\.xml$/))
.sort();
for (const slideFile of slideFiles) {
const content = await zip.files[slideFile].async('text');
// Extract text from XML by stripping tags
const textContent = content
.replace(/<a:t[^>]*>/g, '')
.replace(/<\/a:t>/g, '\n')
.replace(/<[^>]+>/g, '')
.replace(/\n{3,}/g, '\n\n')
.trim();
if (textContent) {
slideTexts.push(textContent);
}
}
return { text: slideTexts.join('\n\n---\n\n'), pageCount: slideFiles.length };
} catch {
throw new AppError('Failed to parse PPTX file', 'PPTX_PARSE_ERROR', 422);
}
}
-343
View File
@@ -1,343 +0,0 @@
import { createHash } from 'node:crypto';
import { isIPv4, isIPv6 } from 'node:net';
import { AppError } from '@api/utils/errors';
import { env } from '@api/config/env';
import { redis, logRedisUnavailableOnce } from '@api/utils/redis/redis';
import { isOrgOnPaidPlan } from '@api/services/agent/usage';
import { ErrorCodes } from '@cio/utils/constants/error-codes';
const JINA_READER_PREFIX = 'https://r.jina.ai/';
const MAX_MARKDOWN_BYTES = 150 * 1024;
const CACHE_TTL_SEC = 7 * 24 * 3600;
const QUOTA_TTL_SEC = 25 * 3600;
export type FetchDocumentationUrlResult = {
url: string;
pageTitle: string;
content: string;
links: string[];
contentTokens: number;
fetchedAt: string;
cacheHit: boolean;
};
type CachedFetchPayload = {
url: string;
pageTitle: string;
rawMarkdown: string;
links: string[];
fetchedAt: string;
};
function isForbiddenHostname(hostname: string): boolean {
const host = hostname.toLowerCase();
if (host === 'localhost' || host.endsWith('.localhost')) {
return true;
}
if (isIPv4(host) || isIPv6(host)) {
return true;
}
return false;
}
export function assertFetchableDocumentationUrl(rawUrl: string): URL {
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
throw new AppError('Invalid documentation URL', 'INVALID_DOCUMENTATION_URL', 400);
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new AppError('Documentation URL must use http or https', 'INVALID_DOCUMENTATION_URL', 400);
}
if (isForbiddenHostname(parsed.hostname)) {
throw new AppError('Documentation URL hostname is not allowed', 'INVALID_DOCUMENTATION_URL', 400);
}
return parsed;
}
function extractSameOriginLinks(markdown: string, origin: URL): string[] {
const seen = new Set<string>();
const out: string[] = [];
const re = /\[[^\]]*\]\((https?:[^)\s]+)\)/g;
let match: RegExpExecArray | null;
while ((match = re.exec(markdown)) !== null) {
try {
const linkUrl = new URL(match[1]);
if (linkUrl.protocol !== 'http:' && linkUrl.protocol !== 'https:') {
continue;
}
if (linkUrl.hostname !== origin.hostname) {
continue;
}
const normalized = linkUrl.href;
if (!seen.has(normalized)) {
seen.add(normalized);
out.push(normalized);
}
} catch {
// skip invalid URL in markdown
}
}
return out;
}
function guessPageTitle(markdown: string, fallbackUrl: string): string {
const trimmed = markdown.trim();
const firstLine = trimmed.split('\n')[0] ?? '';
if (firstLine.startsWith('# ')) {
return firstLine.slice(2).trim();
}
if (firstLine.startsWith('#')) {
return firstLine.replace(/^#+\s*/, '').trim();
}
try {
return new URL(fallbackUrl).hostname;
} catch {
return 'Documentation';
}
}
function truncateMarkdown(body: string): { text: string; truncated: boolean } {
if (body.length <= MAX_MARKDOWN_BYTES) {
return { text: body, truncated: false };
}
const suffix = '\n\n[Truncated: content exceeded 150 KB]';
return {
text: body.slice(0, MAX_MARKDOWN_BYTES - suffix.length) + suffix,
truncated: true
};
}
function wrapUntrustedMarkdown(url: string, markdown: string): string {
const safeSrc = url.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
return `<external_untrusted_document src="${safeSrc}">\n${markdown}\n</external_untrusted_document>`;
}
export function countCompletedFetchDocumentationCalls(messages: unknown[]): number {
let count = 0;
for (const msg of messages as { parts?: unknown[] }[]) {
const parts = msg.parts;
if (!parts) {
continue;
}
for (const part of parts) {
if (!part || typeof part !== 'object') {
continue;
}
const record = part as Record<string, unknown>;
const type = typeof record.type === 'string' ? record.type : '';
let toolName: string | null = null;
if (type === 'tool-invocation') {
toolName = (record.toolInvocation as { toolName?: string } | undefined)?.toolName ?? null;
} else if (type.startsWith('tool-')) {
toolName = type.slice(5);
}
if (toolName !== 'fetch_documentation_url') {
continue;
}
const state = record.state;
if (state === 'result' || state === 'output-available' || state === 'output-error') {
count += 1;
}
}
}
return count;
}
async function redisSafeGet(key: string): Promise<string | null> {
try {
if (!redis.isOpen) {
return null;
}
return await redis.get(key);
} catch (error) {
logRedisUnavailableOnce('fetch-url cache get failed', error);
return null;
}
}
async function redisSafeSet(key: string, value: string, ttlSec: number): Promise<void> {
try {
if (!redis.isOpen) {
return;
}
await redis.set(key, value, { EX: ttlSec });
} catch (error) {
logRedisUnavailableOnce('fetch-url cache set failed', error);
}
}
async function incrementOrgDailyFetchQuota(orgId: string, maxPerDay: number): Promise<void> {
const dayKey = new Date().toISOString().slice(0, 10);
const key = `agent:fetch_url:quota:${orgId}:${dayKey}`;
try {
if (!redis.isOpen) {
return;
}
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, QUOTA_TTL_SEC);
}
if (count > maxPerDay) {
throw new AppError(
'Daily documentation fetch quota exceeded for this organization',
'AGENT_FETCH_QUOTA_EXCEEDED',
429
);
}
} catch (error) {
if (error instanceof AppError) {
throw error;
}
logRedisUnavailableOnce('fetch-url quota redis failed', error);
}
}
async function fetchMarkdownFromJina(url: string): Promise<{ markdown: string; status: number }> {
const readerHref = `${JINA_READER_PREFIX}${url}`;
const headers: Record<string, string> = {};
if (env.JINA_API_KEY) {
headers.Authorization = `Bearer ${env.JINA_API_KEY}`;
}
let response: Response;
try {
response = await fetch(readerHref, {
headers,
redirect: 'follow'
});
} catch {
throw new AppError('Failed to reach documentation reader', 'DOCUMENTATION_FETCH_FAILED', 502);
}
const status = response.status;
const bytesHint = response.headers.get('content-length');
if (!response.ok) {
console.info('[fetch_documentation_url]', { url, status, bytes: bytesHint });
throw new AppError(`Documentation fetch failed with status ${status}`, 'DOCUMENTATION_FETCH_FAILED', 502);
}
const markdown = await response.text();
console.info('[fetch_documentation_url]', { url, status, bytes: String(markdown.length) });
return { markdown, status };
}
export async function fetchDocumentationUrl(params: {
url: string;
orgId: string;
courseId: string;
priorMessages: unknown[];
}): Promise<FetchDocumentationUrlResult> {
const { url, orgId, courseId, priorMessages } = params;
const normalizedUrl = assertFetchableDocumentationUrl(url);
const normalizedHref = normalizedUrl.href;
const priorFetches = countCompletedFetchDocumentationCalls(priorMessages);
if (priorFetches >= env.AGENT_MAX_FETCHES_PER_CONVERSATION) {
throw new AppError('Maximum documentation fetches per conversation reached', 'AGENT_FETCH_CONVERSATION_LIMIT', 429);
}
const paid = await isOrgOnPaidPlan(orgId);
if (!paid) {
throw new AppError('Documentation fetching requires a paid plan', ErrorCodes.UPGRADE_REQUIRED, 403);
}
const cacheKeyRaw = `${orgId}:${normalizedHref}`;
const cacheKey = `agent:fetch_url:${createHash('sha256').update(cacheKeyRaw).digest('hex')}`;
const cachedRaw = await redisSafeGet(cacheKey);
if (cachedRaw) {
try {
const cached = JSON.parse(cachedRaw) as CachedFetchPayload;
const truncated = truncateMarkdown(cached.rawMarkdown);
return {
url: cached.url,
pageTitle: cached.pageTitle,
content: wrapUntrustedMarkdown(cached.url, truncated.text),
links: cached.links,
contentTokens: Math.ceil(truncated.text.length / 4),
fetchedAt: cached.fetchedAt,
cacheHit: true
};
} catch {
// fall through to refetch
}
}
await incrementOrgDailyFetchQuota(orgId, env.AGENT_MAX_FETCHES_PER_ORG_PER_DAY);
const { markdown } = await fetchMarkdownFromJina(normalizedHref);
const truncated = truncateMarkdown(markdown);
const pageTitle = guessPageTitle(truncated.text, normalizedHref);
const links = extractSameOriginLinks(truncated.text, normalizedUrl);
const fetchedAt = new Date().toISOString();
const cachePayload: CachedFetchPayload = {
url: normalizedHref,
pageTitle,
rawMarkdown: truncated.text,
links,
fetchedAt
};
await redisSafeSet(cacheKey, JSON.stringify(cachePayload), CACHE_TTL_SEC);
void courseId;
return {
url: normalizedHref,
pageTitle,
content: wrapUntrustedMarkdown(normalizedHref, truncated.text),
links,
contentTokens: Math.ceil(truncated.text.length / 4),
fetchedAt,
cacheHit: false
};
}
@@ -1,45 +0,0 @@
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function stripLeadingLessonTitle(content: string, lessonTitle: string): string {
const normalizedTitle = lessonTitle.trim();
if (!normalizedTitle) {
return content;
}
const escapedTitle = escapeRegExp(normalizedTitle);
const patterns = [
new RegExp(`^\\s*<h[1-6][^>]*>\\s*${escapedTitle}\\s*</h[1-6]>\\s*`, 'i'),
new RegExp(
`^\\s*<p[^>]*>\\s*(?:<strong[^>]*>|<b[^>]*>)?\\s*${escapedTitle}\\s*(?:</strong>|</b>)?\\s*</p>\\s*`,
'i'
)
];
let nextContent = content;
for (const pattern of patterns) {
nextContent = nextContent.replace(pattern, '');
}
return nextContent;
}
function normalizeHeadingLevels(content: string): string {
return content
.replace(/<h1(\s|>)/gi, '<h3$1')
.replace(/<\/h1>/gi, '</h3>')
.replace(/<h2(\s|>)/gi, '<h3$1')
.replace(/<\/h2>/gi, '</h3>');
}
export function normalizeAgentLessonContent(content: string, lessonTitle: string): string {
let normalizedContent = content.trim();
normalizedContent = stripLeadingLessonTitle(normalizedContent, lessonTitle);
normalizedContent = normalizeHeadingLevels(normalizedContent);
return normalizedContent.trim();
}
@@ -1,108 +0,0 @@
import { randomUUID } from 'node:crypto';
import { getChatModelContext, upsertChatModelContext } from '@cio/db/queries/agent';
import { trimMessageHistory } from '@api/services/agent/context-window';
import { summarizeConversationDelta, type SummarizeOptions } from '@api/services/agent/summarize';
const KEEP_RECENT_MESSAGES = 16;
const MIN_MESSAGES_BEFORE_SUMMARY = KEEP_RECENT_MESSAGES + 4;
type AgentMessage = SummarizeOptions['messages'][number] & {
id?: string;
metadata?: Record<string, unknown> | null | undefined;
};
export interface BuildModelContextMessagesOptions {
conversationId?: string;
courseId: string;
userId: string;
messages: AgentMessage[];
}
export interface BuildModelContextMessagesResult {
messages: AgentMessage[];
summarized: boolean;
compactedThroughMessageId?: string | null;
}
function getMessageId(message: AgentMessage | undefined): string | null {
return typeof message?.id === 'string' && message.id.length > 0 ? message.id : null;
}
function buildSummarySeed(summary: string, compactedThroughMessageId?: string | null): AgentMessage {
return {
id: `model-context-summary-${randomUUID()}`,
role: 'user',
parts: [
{
type: 'text',
text: `Conversation summary for continuity. Use this as prior context, then continue from the recent messages that follow.\n\n${summary}`
}
],
metadata: {
modelContextSummary: {
compactedThroughMessageId: compactedThroughMessageId ?? null
}
}
};
}
export async function buildModelContextMessages(
options: BuildModelContextMessagesOptions
): Promise<BuildModelContextMessagesResult> {
const { conversationId, courseId, userId, messages } = options;
if (!conversationId || messages.length <= MIN_MESSAGES_BEFORE_SUMMARY) {
return { messages: trimMessageHistory(messages) as AgentMessage[], summarized: false };
}
const existingContext = await getChatModelContext(conversationId, userId);
const splitIndex = Math.max(0, messages.length - KEEP_RECENT_MESSAGES);
const messagesToRepresent = messages.slice(0, splitIndex);
const recentMessages = messages.slice(splitIndex);
const compactedThroughMessageId = getMessageId(messagesToRepresent[messagesToRepresent.length - 1]);
if (messagesToRepresent.length === 0) {
return { messages: trimMessageHistory(messages) as AgentMessage[], summarized: false };
}
let modelSummary = existingContext?.modelSummary ?? '';
let summarized = false;
if (compactedThroughMessageId && existingContext?.compactedThroughMessageId !== compactedThroughMessageId) {
const previousCompactedIndex = existingContext?.compactedThroughMessageId
? messagesToRepresent.findIndex((message) => getMessageId(message) === existingContext.compactedThroughMessageId)
: -1;
const deltaStartIndex = previousCompactedIndex >= 0 ? previousCompactedIndex + 1 : 0;
const deltaMessages = messagesToRepresent.slice(deltaStartIndex);
if (deltaMessages.length > 0) {
modelSummary = await summarizeConversationDelta({
existingSummary: modelSummary,
messages: deltaMessages
});
summarized = true;
}
await upsertChatModelContext({
conversationId,
courseId,
userId,
modelSummary,
compactedThroughMessageId,
sourceIds: existingContext?.sourceIds ?? []
});
}
if (!modelSummary.trim()) {
return { messages: trimMessageHistory(messages) as AgentMessage[], summarized };
}
const contextManagedMessages = [buildSummarySeed(modelSummary, compactedThroughMessageId), ...recentMessages];
return {
messages: trimMessageHistory(contextManagedMessages) as AgentMessage[],
summarized,
compactedThroughMessageId
};
}
@@ -1,119 +0,0 @@
import { AppError } from '@api/utils/errors';
export type CurrentQuestionForPatch = {
id: number | string;
title: string;
questionTypeId: number;
points: number;
order: number;
exerciseSectionId?: string | null;
settings?: Record<string, unknown>;
options: Array<{
id: number | string;
label: string | null;
isCorrect: boolean;
settings?: Record<string, unknown>;
}>;
};
export type QuestionPatch = {
id: number;
question?: string;
questionTypeId?: number;
points?: number;
order?: number;
exerciseSectionId?: string | null;
settings?: Record<string, unknown>;
options?: Array<{
id?: number;
label?: string;
isCorrect?: boolean;
settings?: Record<string, unknown>;
}>;
};
export type MergedOption = {
id?: number;
label: string;
isCorrect: boolean;
settings?: Record<string, unknown>;
};
export type MergedQuestion = {
id: number;
question: string;
questionTypeId: number;
points: number;
order: number;
exerciseSectionId?: string | null;
settings?: Record<string, unknown>;
options: MergedOption[];
};
export function buildUpdatedQuestions(
currentQuestions: CurrentQuestionForPatch[],
patches: QuestionPatch[],
exerciseId: string
): MergedQuestion[] {
const byId = new Map(currentQuestions.map((q) => [Number(q.id), q] as const));
return patches.map((patch) => {
const current = byId.get(patch.id);
if (!current) {
throw new AppError(
`Question ${patch.id} does not belong to exercise ${exerciseId}`,
'RESOURCE_NOT_IN_COURSE',
404
);
}
const currentOptions: MergedOption[] = (current.options || []).map((o) => ({
id: Number(o.id),
label: o.label || '',
isCorrect: o.isCorrect,
settings: o.settings ?? undefined
}));
const mergedSettings =
patch.settings === undefined ? current.settings : { ...(current.settings ?? {}), ...patch.settings };
let mergedOptions: MergedOption[];
if (patch.options === undefined) {
mergedOptions = currentOptions;
} else {
mergedOptions = patch.options.map((opt) => {
if (opt.id != null) {
const existingOpt = currentOptions.find((co) => co.id === opt.id);
return {
id: opt.id,
label: opt.label ?? existingOpt?.label ?? '',
isCorrect: opt.isCorrect ?? existingOpt?.isCorrect ?? false,
settings: opt.settings ?? existingOpt?.settings
};
}
return {
label: opt.label ?? '',
isCorrect: opt.isCorrect ?? false,
settings: opt.settings
};
});
}
const merged: MergedQuestion = {
id: patch.id,
question: patch.question ?? current.title ?? '',
questionTypeId: patch.questionTypeId ?? current.questionTypeId,
points: patch.points ?? current.points,
order: patch.order ?? current.order,
settings: mergedSettings,
options: mergedOptions
};
if (patch.exerciseSectionId !== undefined) {
merged.exerciseSectionId = patch.exerciseSectionId;
}
return merged;
});
}
+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(
@@ -1,138 +0,0 @@
/**
* Anthropic strictly requires every `tool_use` block to be followed in the very
* next message by a `tool_result` block with a matching id. OpenAI tolerates
* mismatches; Anthropic 400s.
*
* The break-shapes this catches:
* (a) `convertToModelMessages` splits an assistant UIMessage with parts
* `[tool-call, text, text]` into TWO assistant ModelMessages plus a tool
* message — the wedged second assistant message separates tool_use from
* tool_result.
* (b) `trimMessageHistory` slices the array between an assistant tool-call
* turn and its tool-result message.
* (c) A tool call was aborted mid-stream and persisted without a result.
*
* Strategy: walk the ModelMessage[] once. For every assistant message that has
* tool-call parts, **immediately** emit a tool message containing matching
* tool-result parts — pulled from anywhere later in the array, or synthesized
* as an error result if missing entirely. Tool messages whose results have
* already been claimed by an earlier assistant turn are dropped (or trimmed)
* to avoid duplicates.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
type ToolCallPart = { type: 'tool-call' | 'dynamic-tool-call'; toolCallId: string; toolName: string };
type ToolResultPart = {
type: 'tool-result' | 'dynamic-tool-result';
toolCallId: string;
toolName?: string;
output?: unknown;
};
function isToolCallPart(part: unknown): part is ToolCallPart {
const type = (part as { type?: unknown })?.type;
return type === 'tool-call' || type === 'dynamic-tool-call';
}
function isToolResultPart(part: unknown): part is ToolResultPart {
const type = (part as { type?: unknown })?.type;
return type === 'tool-result' || type === 'dynamic-tool-result';
}
function syntheticErrorResult(tc: ToolCallPart): ToolResultPart {
return {
type: 'tool-result',
toolCallId: tc.toolCallId,
toolName: tc.toolName,
output: { type: 'error-text', value: 'Tool call was interrupted; no result captured.' }
};
}
/**
* Anthropic accepts assistant content in the canonical shape `[text*, tool_use*]`
* (text/reasoning first, then tool_uses at the end). When the assistant content
* has text AFTER tool_use blocks (`[tool_use, text, text]`), Anthropic's strict
* validator can reject it with the same "tool_result missing immediately after"
* error even though the next message IS a tool message — because it doesn't
* recognise the assistant turn as ending on tool_use. Reorder so tool-calls
* always come last within each assistant message.
*/
function reorderAssistantContent(content: any[]): any[] {
const toolCalls: any[] = [];
const nonToolCalls: any[] = [];
for (const part of content) {
if (isToolCallPart(part)) {
toolCalls.push(part);
} else {
nonToolCalls.push(part);
}
}
// Only return a new array if we actually moved something.
if (toolCalls.length === 0 || nonToolCalls.length === 0) return content;
return [...nonToolCalls, ...toolCalls];
}
export function sanitizeDanglingToolCalls(messages: any[]): any[] {
// Pass 1 — index every tool-result we can find anywhere in the conversation,
// keyed by toolCallId. If duplicates exist, last one wins (most recent).
const toolResultsByCallId = new Map<string, ToolResultPart>();
for (const m of messages) {
if (m?.role !== 'tool' || !Array.isArray(m.content)) continue;
for (const part of m.content) {
if (isToolResultPart(part)) {
toolResultsByCallId.set(part.toolCallId, part);
}
}
}
// Pass 2 — rebuild. After each assistant message with tool-calls, inject a
// tool message whose results match those calls in the same order. Skip the
// original tool messages whose parts we've already claimed. Also reorder
// assistant content so tool-calls always sit at the END.
const claimedResultIds = new Set<string>();
const result: any[] = [];
for (let i = 0; i < messages.length; i += 1) {
const message = messages[i];
// Filter or drop tool messages whose results were already pulled forward.
if (message?.role === 'tool' && Array.isArray(message.content)) {
const remaining = message.content.filter(
(part: unknown) => !isToolResultPart(part) || !claimedResultIds.has(part.toolCallId)
);
if (remaining.length > 0) {
result.push({ ...message, content: remaining });
}
continue;
}
if (message?.role !== 'assistant' || !Array.isArray(message.content)) {
result.push(message);
continue;
}
const reorderedContent = reorderAssistantContent(message.content);
const reorderedMessage = reorderedContent === message.content ? message : { ...message, content: reorderedContent };
result.push(reorderedMessage);
const toolCalls = reorderedContent.filter(isToolCallPart);
if (toolCalls.length === 0) continue;
const resultParts: ToolResultPart[] = toolCalls.map((tc: ToolCallPart) => {
claimedResultIds.add(tc.toolCallId);
const existing = toolResultsByCallId.get(tc.toolCallId);
return existing ?? syntheticErrorResult(tc);
});
result.push({ role: 'tool', content: resultParts });
}
return result;
}
+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.
-186
View File
@@ -1,186 +0,0 @@
import { generateText } from 'ai';
import { createModel, pickAnyConfiguredProvider } from '@cio/ai-assistant/providers';
import { AppError } from '@api/utils/errors';
const SUMMARIZE_SYSTEM_PROMPT = `You are summarizing a multi-turn ClassroomIO course-creation chat so the assistant can pick up exactly where it left off in a new conversation.
Write the summary as a first-person user message to the assistant. It must be DETAILED — assume the receiving assistant has zero prior context.
Required structure (use these exact section headings, written in plain text without markdown):
1. Course context: course title, intended audience, primary outcomes, and the active template flow (Product 101, Product Onboarding, Expert on X, or other) if any.
2. Template form answers: every answered field by key and value, including the chosen depth tier (light / balanced / deep_doc) and any documentation URL provided.
3. Documentation fetched: list every URL that was fetched via fetch_documentation_url and a one-line note on what was learned from each (key product surface area, vocabulary, workflows). Do NOT skip this section if any docs were fetched.
4. Plan status: whether a course plan has been generated, the plan title and section count, and whether it was approved, edited, or still pending changes.
5. Implementation progress: which sections / lessons / exercises were already created (by title), and any tool errors hit.
6. Open decisions: anything the teacher said they still wanted to do, change, or get an answer on.
7. Continue by: an explicit next action for the assistant (e.g. "Fetch the remaining linked docs and then call generate_course_plan", or "Resume implementing from section 4").
Length target: at least 250 words; expand as needed to capture detail. Do not write a high-level paragraph; write the structured handoff above. Never abbreviate or omit sections — write "(none)" if a section is empty.`;
interface InboundMessagePart {
type: string;
text?: string;
toolName?: string;
state?: string;
input?: unknown;
output?: unknown;
}
interface InboundMessage {
role: string;
content?: string;
parts?: InboundMessagePart[];
metadata?: Record<string, unknown> | null | undefined;
}
export interface SummarizeOptions {
messages: InboundMessage[];
}
export interface SummarizeDeltaOptions extends SummarizeOptions {
existingSummary?: string | null;
}
function truncate(input: string, max: number): string {
if (input.length <= max) return input;
return `${input.slice(0, max)}…`;
}
function stringifySafely(value: unknown): string {
try {
return typeof value === 'string' ? value : JSON.stringify(value);
} catch {
return '[unserializable]';
}
}
function partsToText(message: InboundMessage): string {
const lines: string[] = [];
if (message.content && message.content.trim().length > 0) {
lines.push(message.content.trim());
}
for (const part of message.parts ?? []) {
if (part.type === 'text' && part.text) {
lines.push(part.text.trim());
continue;
}
if (part.type === 'tool-invocation' || part.type?.startsWith('tool-')) {
const toolName = part.toolName ?? (part.type.startsWith('tool-') ? part.type.slice(5) : 'unknown_tool');
const inputText = part.input !== undefined ? truncate(stringifySafely(part.input), 600) : '';
const outputText = part.output !== undefined ? truncate(stringifySafely(part.output), 1500) : '';
const segments: string[] = [`[tool:${toolName}]`];
if (inputText) {
segments.push(`input=${inputText}`);
}
if (outputText) {
segments.push(`output=${outputText}`);
}
lines.push(segments.join(' '));
}
}
const metadataTemplate = message.metadata?.template;
if (metadataTemplate && typeof metadataTemplate === 'object') {
lines.push(`[template-action] ${truncate(stringifySafely(metadataTemplate), 600)}`);
}
return lines.join('\n');
}
const MAX_CONVERSATION_CHARS = 60_000;
const MAX_OUTPUT_TOKENS = 2_000;
export async function summarizeConversation(options: SummarizeOptions): Promise<string> {
const providerConfig = pickAnyConfiguredProvider();
if (!providerConfig) {
throw new AppError('AI assistant is not configured', 'AI_NOT_CONFIGURED', 503);
}
const model = createModel(providerConfig);
const conversationLines: string[] = [];
for (const message of options.messages ?? []) {
const role = message.role === 'user' ? 'User' : 'Assistant';
const body = partsToText(message);
if (!body.trim()) {
continue;
}
conversationLines.push(`${role}: ${body}`);
}
const conversationText = conversationLines.join('\n\n');
// Prefer keeping the END of the transcript when truncating — recent context is most
// valuable for "continue from here" continuations.
const truncatedText =
conversationText.length > MAX_CONVERSATION_CHARS
? conversationText.slice(conversationText.length - MAX_CONVERSATION_CHARS)
: conversationText;
const result = await generateText({
model,
system: SUMMARIZE_SYSTEM_PROMPT,
prompt: `Here is the conversation to summarize. Tool calls appear as [tool:name] input=… output=… lines.\n\n${truncatedText}\n\nReturn the structured handoff message described in the system prompt.`,
maxOutputTokens: MAX_OUTPUT_TOKENS,
maxRetries: 0
});
return result.text.trim();
}
export async function summarizeConversationDelta(options: SummarizeDeltaOptions): Promise<string> {
if (!options.existingSummary?.trim()) {
return summarizeConversation({ messages: options.messages });
}
const providerConfig = pickAnyConfiguredProvider();
if (!providerConfig) {
throw new AppError('AI assistant is not configured', 'AI_NOT_CONFIGURED', 503);
}
const model = createModel(providerConfig);
const conversationLines: string[] = [];
for (const message of options.messages ?? []) {
const role = message.role === 'user' ? 'User' : 'Assistant';
const body = partsToText(message);
if (!body.trim()) {
continue;
}
conversationLines.push(`${role}: ${body}`);
}
const conversationText = conversationLines.join('\n\n');
const truncatedText =
conversationText.length > MAX_CONVERSATION_CHARS
? conversationText.slice(conversationText.length - MAX_CONVERSATION_CHARS)
: conversationText;
const result = await generateText({
model,
system: SUMMARIZE_SYSTEM_PROMPT,
prompt: `Existing structured handoff summary:\n\n${options.existingSummary.trim()}\n\nNew conversation messages to merge into that summary:\n\n${truncatedText}\n\nReturn one updated structured handoff message using the required section headings. Preserve concrete plan status, template answers, fetched documentation URLs/source identifiers, created course items, open decisions, and the next action.`,
maxOutputTokens: MAX_OUTPUT_TOKENS,
maxRetries: 0
});
return result.text.trim();
}
+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;
-227
View File
@@ -1,227 +0,0 @@
import type { TokenBalance, TokenUsage } from '@cio/ai-assistant';
import {
aggregateTokenUsageByUser,
countRequests,
getDailyTokenUsageHistory,
getMonthlyTokenUsage,
getOrgCreditBalance,
insertTokenUsageAndDrainCredits,
summarizePurchases,
upsertCreditBalance
} from '@cio/db/queries/agent';
import type { UsageLeaderboardRow } from '@cio/db/queries/agent';
import { getActiveOrganizationPlan } from '@cio/db/queries/organization';
import { AppError } from '@api/utils/errors';
function startOfCurrentMonth(): Date {
const date = new Date();
date.setDate(1);
date.setHours(0, 0, 0, 0);
return date;
}
// ─── Plan-Based Token Allowances ─────────────────────────────────────────────
const PLAN_TOKEN_ALLOWANCES: Record<string, number> = {
BASIC: 500_000,
EARLY_ADOPTER: 3_000_000,
ENTERPRISE: 15_000_000
};
// ─── Model Cost Multipliers ───────────────────────────────────────────────────
// Blended multiplier vs Gemini 3.1 Flash Lite baseline ($0.50/1M blended at 80/20 input/output mix); unknown → 1×.
// gemini-2.5-flash kept for backward compat with historical usage records.
const MODEL_COST_MULTIPLIER: Record<string, number> = {
'gemini-3.1-flash-lite': 1,
'gemini-2.5-flash': 1.5,
'gpt-5.4-mini': 4,
'claude-sonnet-4-6': 11,
'claude-haiku-4-5-20251001': 1.5,
'kimi-k2.6': 4
};
function computeCostUnits(promptTokens: number, completionTokens: number, model: string): number {
const multiplier = MODEL_COST_MULTIPLIER[model] ?? 1;
return Math.round((promptTokens + completionTokens) * multiplier);
}
async function getPlanAllowance(orgId: string): Promise<{ planName: string; allowance: number }> {
const activePlan = await getActiveOrganizationPlan(orgId);
if (!activePlan || !activePlan.planName) {
return { planName: 'BASIC', allowance: PLAN_TOKEN_ALLOWANCES.BASIC };
}
const planName = activePlan.planName;
const payload = activePlan.payload as Record<string, unknown> | null;
const customAllowance = payload?.aiTokenAllowance as number | undefined;
const allowance = customAllowance ?? PLAN_TOKEN_ALLOWANCES[planName] ?? 0;
return { planName, allowance };
}
export async function getMonthlyUsage(orgId: string): Promise<number> {
return getMonthlyTokenUsage(orgId, startOfCurrentMonth());
}
export async function getCreditBalance(orgId: string): Promise<number> {
return getOrgCreditBalance(orgId);
}
export async function getTokenBalance(orgId: string): Promise<TokenBalance> {
const [{ allowance }, monthlyUsage, creditBalance] = await Promise.all([
getPlanAllowance(orgId),
getMonthlyUsage(orgId),
getCreditBalance(orgId)
]);
const remainingAllowance = Math.max(0, allowance - monthlyUsage);
const remaining = remainingAllowance + creditBalance;
return {
used: monthlyUsage,
allowance,
creditBalance,
remaining
};
}
export async function enforceTokenBalance(orgId: string): Promise<TokenBalance> {
const balance = await getTokenBalance(orgId);
if (balance.remaining <= 0) {
throw new AppError('Token limit reached', 'TOKEN_LIMIT_REACHED', 402);
}
return balance;
}
export function computePurchasedTokenOverflow(params: {
allowance: number;
monthlyUsageBefore: number;
requestTokens: number;
}): number {
const allowanceRemainingBefore = Math.max(0, params.allowance - params.monthlyUsageBefore);
return Math.max(0, params.requestTokens - allowanceRemainingBefore);
}
/** Record token usage after an LLM call; drains purchased credits when usage exceeds plan allowance. */
export async function recordTokenUsage(
orgId: string,
userId: string,
courseId: string,
usage: TokenUsage,
model: string
): Promise<void> {
const costUnits = computeCostUnits(usage.promptTokens, usage.completionTokens, model);
const { allowance } = await getPlanAllowance(orgId);
await insertTokenUsageAndDrainCredits({
orgId,
userId,
courseId,
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
costUnits,
model,
planAllowance: allowance,
since: startOfCurrentMonth()
});
}
export async function getOrgPlanName(orgId: string): Promise<string> {
const { planName } = await getPlanAllowance(orgId);
return planName;
}
export async function isOrgOnPaidPlan(orgId: string): Promise<boolean> {
const planName = await getOrgPlanName(orgId);
return planName !== 'BASIC';
}
// ─── Usage History ───────────────────────────────────────────────────────────
export interface DailyUsage {
date: string;
tokens: number;
}
export async function getUsageHistory(orgId: string): Promise<DailyUsage[]> {
return getDailyTokenUsageHistory(orgId, startOfCurrentMonth());
}
export interface PurchasedSummary {
totalPurchasedTokens: number;
totalSpentCents: number;
currency: 'USD';
currentBalance: number;
lastPurchaseAt: string | null;
}
export async function getPurchasedSummary(orgId: string): Promise<PurchasedSummary> {
const [summary, currentBalance] = await Promise.all([summarizePurchases(orgId), getCreditBalance(orgId)]);
return {
totalPurchasedTokens: summary.totalPurchasedTokens,
totalSpentCents: summary.totalSpentCents,
currency: 'USD',
currentBalance,
lastPurchaseAt: summary.lastPurchaseAt
};
}
export interface LeaderboardEntry {
userId: string;
fullname: string | null;
email: string | null;
avatarUrl: string | null;
tokens: number;
requests: number;
favoriteModel: string | null;
percentage: number;
}
export async function getTeamLeaderboard(orgId: string): Promise<{ entries: LeaderboardEntry[]; totalTokens: number }> {
const rows: UsageLeaderboardRow[] = await aggregateTokenUsageByUser(orgId, startOfCurrentMonth());
const totalTokens = rows.reduce((sum: number, row: UsageLeaderboardRow) => sum + row.tokens, 0);
const entries: LeaderboardEntry[] = rows.map((row: UsageLeaderboardRow) => ({
userId: row.userId,
fullname: row.fullname,
email: row.email,
avatarUrl: row.avatarUrl,
tokens: row.tokens,
requests: row.requests,
favoriteModel: row.favoriteModel,
percentage: totalTokens > 0 ? row.tokens / totalTokens : 0
}));
return { entries, totalTokens };
}
export async function getDetailedUsage(orgId: string) {
const start = startOfCurrentMonth();
const [balance, history, requestsThisMonth] = await Promise.all([
getTokenBalance(orgId),
getDailyTokenUsageHistory(orgId, start),
countRequests(orgId, start)
]);
return { ...balance, history, requestsThisMonth };
}
// ─── Credit Purchases ────────────────────────────────────────────────────────
export async function addCredits(orgId: string, amount: number): Promise<number> {
if (amount <= 0) {
throw new AppError('Credit amount must be positive', 'INVALID_CREDIT_AMOUNT', 400);
}
return upsertCreditBalance(orgId, amount);
}
+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) {
-256
View File
@@ -1,256 +0,0 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import type { ContentfulStatusCode } from 'hono/utils/http-status';
import { ContentType } from '@cio/utils/constants';
import type { DbOrTxClient } from '@cio/db/drizzle';
import { db } from '@cio/db/drizzle';
import { applyCourseContentBulkUpdates, applyCourseSectionOrderUpdates } from '@cio/db/queries/course/content-batch';
import { deleteCourseSection, getCourseSectionById, getCourseSectionsByCourseId } from '@cio/db/queries/course';
import { OperationalQueryError } from '@cio/db/queries/query-errors';
import { getCourseContentItems, type CourseContentItemRow } from '@cio/db/queries/course/content';
import { deleteLesson } from '@cio/db/queries/lesson';
import { deleteExercise } from '@cio/db/queries/exercise/exercise';
import type { TCourseContentDelete, TCourseContentReorder, TCourseContentUpdate } from '@cio/utils/validation/course';
type CourseContentReorderResult = {
courseId: string;
updatedSections: number;
updatedItems: number;
updatedLessons: number;
updatedExercises: number;
};
function buildContentKey(item: { id: string; type: string }) {
return `${item.type}-${item.id}`;
}
function normalizeDeleteItems<TItem extends { id: string; type: string }>(items: TItem[]): TItem[] {
const seenKeys = new Set<string>();
const uniqueItems: TItem[] = [];
for (const item of items) {
const itemKey = buildContentKey(item);
if (seenKeys.has(itemKey)) {
continue;
}
seenKeys.add(itemKey);
uniqueItems.push(item);
}
const exercises = uniqueItems.filter((item) => item.type === ContentType.Exercise);
const lessons = uniqueItems.filter((item) => item.type === ContentType.Lesson);
return [...exercises, ...lessons];
}
async function getCourseContentMap(courseId: string) {
const contentItems = await getCourseContentItems(courseId);
const contentMap = new Map<string, CourseContentItemRow>();
contentItems.forEach((item) => {
if (item.type === ContentType.Lesson || item.type === ContentType.Exercise) {
contentMap.set(buildContentKey(item), item);
}
});
return contentMap;
}
async function assertCourseContentItems(courseId: string, items: TCourseContentUpdate['items']) {
const contentMap = await getCourseContentMap(courseId);
items.forEach((item) => {
const key = buildContentKey({ id: item.id, type: item.type });
if (!contentMap.has(key)) {
if (item.type === ContentType.Lesson) {
throw new AppError('Lesson does not belong to this course', ErrorCodes.LESSON_NOT_FOUND, 404);
}
if (item.type === ContentType.Exercise) {
throw new AppError('Exercise does not belong to this course', ErrorCodes.EXERCISE_NOT_FOUND, 404);
}
throw new AppError('Invalid content type', ErrorCodes.VALIDATION_ERROR, 400);
}
});
}
async function assertCourseSections(courseId: string, sections: NonNullable<TCourseContentReorder['sections']>) {
const existingSections = await getCourseSectionsByCourseId(courseId);
const sectionIds = new Set(existingSections.map((section) => section.id));
sections.forEach((section) => {
if (!sectionIds.has(section.id)) {
throw new AppError('Course section not found', ErrorCodes.COURSE_SECTION_NOT_FOUND, 404);
}
});
}
function mapContentBatchError(error: unknown): AppError | null {
if (error instanceof OperationalQueryError) {
return new AppError(error.message, error.errorCode, error.statusCode as ContentfulStatusCode, error.field);
}
return null;
}
async function transactionWithContentBatch<TResult>(runner: (tx: DbOrTxClient) => Promise<TResult>): Promise<TResult> {
try {
return await db.transaction(async (tx) => runner(tx));
} catch (error) {
if (error instanceof AppError) {
throw error;
}
const mapped = mapContentBatchError(error);
if (mapped) {
throw mapped;
}
throw error;
}
}
export async function updateCourseContent(courseId: string, items: TCourseContentUpdate['items']) {
try {
await assertCourseContentItems(courseId, items);
await transactionWithContentBatch(async (tx) => applyCourseContentBulkUpdates(tx, items));
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to update course content',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function reorderCourseContent(
courseId: string,
payload: TCourseContentReorder
): Promise<CourseContentReorderResult> {
try {
if (payload.sections?.length) {
await assertCourseSections(courseId, payload.sections);
}
if (payload.items?.length) {
await assertCourseContentItems(courseId, payload.items);
}
return await transactionWithContentBatch(async (tx) => {
const updatedSections = payload.sections?.length ? await applyCourseSectionOrderUpdates(tx, payload.sections) : 0;
const itemResult = payload.items?.length
? await applyCourseContentBulkUpdates(tx, payload.items)
: {
updatedItems: 0,
updatedLessons: 0,
updatedExercises: 0
};
return {
courseId,
updatedSections,
...itemResult
};
});
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to reorder course content',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function deleteCourseContent(courseId: string, payload: TCourseContentDelete) {
try {
if (payload.sectionId) {
const sectionId = payload.sectionId;
const section = await getCourseSectionById(sectionId);
if (!section || section.courseId !== courseId) {
throw new AppError('Course section not found', ErrorCodes.COURSE_SECTION_NOT_FOUND, 404);
}
const contentItems = await getCourseContentItems(courseId);
const itemsToDelete = normalizeDeleteItems(
contentItems.filter(
(item) =>
(item.type === ContentType.Lesson || item.type === ContentType.Exercise) && item.sectionId === sectionId
)
);
console.log('Items to delete:', itemsToDelete);
await db.transaction(async (tx) => {
for (const item of itemsToDelete) {
if (item.type === ContentType.Lesson) {
const deleted = await deleteLesson(item.id, tx);
if (!deleted) {
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
}
continue;
}
const deleted = await deleteExercise(item.id, tx);
if (!deleted) {
console.log('delete exercise failed for id:', item.id);
throw new AppError('Exercise not found', ErrorCodes.EXERCISE_NOT_FOUND, 404);
}
}
const deletedSection = await deleteCourseSection(sectionId, tx);
if (!deletedSection) {
throw new AppError('Course section not found', ErrorCodes.COURSE_SECTION_NOT_FOUND, 404);
}
});
return;
}
if (!payload.items) {
throw new AppError('Content items are required', ErrorCodes.VALIDATION_ERROR, 400);
}
const items = payload.items;
await assertCourseContentItems(courseId, items);
const normalizedItems = normalizeDeleteItems(items);
await db.transaction(async (tx) => {
for (const item of normalizedItems) {
if (item.type === ContentType.Lesson) {
const deleted = await deleteLesson(item.id, tx);
if (!deleted) {
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
}
continue;
}
const deleted = await deleteExercise(item.id, tx);
if (!deleted) {
throw new AppError('Exercise not found', ErrorCodes.EXERCISE_NOT_FOUND, 404);
}
}
});
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to delete course content',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
-580
View File
@@ -1,580 +0,0 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { sanitizeHtml, sanitizeOptionalHtml, sanitizeUnknownStrings } from '@api/utils/sanitize-html';
import {
createCourseNewsfeed,
createCourse as createCourseQuery,
createCourseSections,
deleteCourse as deleteCourseQuery,
getCourseProgress as getCourseProgressQuery,
getCourseTypeById,
getCourseWithRelations,
updateCourse as updateCourseQuery,
updateLessonsSectionId
} from '@cio/db/queries/course';
import {
addGroupMember,
createGroup,
getCourseProgramAccess,
insertGroupMembersOnConflictDoNothing
} from '@cio/db/queries/group';
import {
getLastLogin,
getLessonsWithCompletion,
getProfileCourseProgress,
getUserExercisesStats
} from '@cio/db/queries/analytics/analytics';
import { ContentType, ROLE } from '@cio/utils/constants';
import type { TCourse } from '@cio/db/types';
import type { TCourseCreate } from '@cio/utils/validation/course';
import { db } from '@cio/db/drizzle';
import { exerciseBelongsToCourse } from '@cio/db/queries/course/certification-exercise';
import { updateExercisesSectionId } from '@cio/db/queries/exercise/exercise';
import { getProfileById } from '@cio/db/queries/auth';
import { insertOrganizationMembersOnConflictDoNothing } from '@cio/db/queries/organization';
import { buildCourseContent, calcPercentageWithRounding, formatLastSeen, type CourseContent } from './utils';
import { guardCourseTypeTransition } from './public-course-guard';
const DEFAULT_CONTENT_GROUPING = true;
const DEFAULT_SECTION_TITLE = 'First Section [edit me]';
export async function ensureProgramCourseAccess(courseId: string, profileId: string) {
const access = await getCourseProgramAccess(courseId, profileId);
if (!access) {
return false;
}
const organizationId = access.organizationId;
if (!organizationId) {
return false;
}
const normalizedEmail = access.profileEmail?.trim() || undefined;
await db.transaction(async (tx) => {
await insertOrganizationMembersOnConflictDoNothing(
[
{
organizationId,
roleId: access.roleId,
profileId,
email: normalizedEmail,
verified: true
}
],
tx
);
await insertGroupMembersOnConflictDoNothing(
[
{
groupId: access.courseGroupId,
roleId: access.roleId,
profileId,
email: normalizedEmail ?? undefined
}
],
tx
);
});
return true;
}
function sanitizeCourseMetadata(metadata: TCourse['metadata'] | undefined) {
if (!metadata) return metadata;
return {
...metadata,
description: sanitizeOptionalHtml(metadata.description),
goals: sanitizeOptionalHtml(metadata.goals),
requirements: sanitizeOptionalHtml(metadata.requirements),
reward: metadata.reward
? {
...metadata.reward,
description: sanitizeHtml(metadata.reward.description)
}
: metadata.reward,
instructor: metadata.instructor
? {
...metadata.instructor,
description: sanitizeHtml(metadata.instructor.description)
}
: metadata.instructor,
reviews: metadata.reviews?.map((review) => ({
...review,
description: sanitizeHtml(review.description)
}))
};
}
function sanitizeCourseCertificate(certificate: TCourse['certificate'] | undefined) {
if (!certificate) return certificate;
return {
...certificate,
emailMessage: sanitizeOptionalHtml(certificate.emailMessage)
};
}
/**
* Gets a course by ID or slug with all related data
* @param courseId Course ID (optional if slug provided)
* @param slug Course slug (optional if courseId provided)
* @param profileId Profile ID of the current user (optional, for computing lesson completion)
* @returns Course with all related data
*/
export async function getCourse(courseId?: string, slug?: string, profileId?: string) {
try {
if (!courseId && !slug) {
throw new AppError('Either courseId or slug must be provided', ErrorCodes.VALIDATION_ERROR, 400);
}
const course = await getCourseWithRelations(courseId, slug, profileId);
if (!course) {
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
}
const isContentGroupingEnabled = course.metadata?.isContentGroupingEnabled ?? DEFAULT_CONTENT_GROUPING;
const content = buildCourseContent(course.contentItems, isContentGroupingEnabled);
const { contentItems, org: courseOrg, ...rest } = course;
const base = {
...rest,
content
};
if (courseOrg) {
return {
...base,
org: {
id: courseOrg.id,
name: courseOrg.name,
siteName: courseOrg.siteName ?? '',
theme: courseOrg.theme ?? undefined
}
};
}
return base;
} catch (error) {
console.error('Error getting course:', error);
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to fetch course',
ErrorCodes.COURSE_FETCH_FAILED,
500
);
}
}
/**
* Creates a new course with group, group member, and default newsfeed
* @param profileId Profile ID of the creator
* @param data Course creation data
* @returns Created course with group and member information
*/
export async function createCourse(
profileId: string,
data: TCourseCreate
): Promise<{ course: TCourse; groupId: string; memberId: string }> {
try {
const description = sanitizeHtml(data.description);
const result = await db.transaction(async (tx) => {
const [newGroup] = await createGroup(
{
name: data.title,
description,
organizationId: data.organizationId
},
tx
);
if (!newGroup) {
throw new AppError('Failed to create group', ErrorCodes.INTERNAL_ERROR, 500);
}
const [newCourse] = await createCourseQuery(
{
title: data.title,
description,
type: data.type,
groupId: newGroup.id,
compliance: data.compliance ? sanitizeUnknownStrings(data.compliance) : undefined
},
tx
);
if (!newCourse) {
throw new AppError('Failed to create course', ErrorCodes.INTERNAL_ERROR, 500);
}
const [newMember] = await addGroupMember(
{
profileId,
groupId: newGroup.id,
roleId: ROLE.TUTOR
},
tx
);
if (!newMember) {
throw new AppError('Failed to add group member', ErrorCodes.INTERNAL_ERROR, 500);
}
await createCourseNewsfeed(
{
content: sanitizeHtml(`<h2>Welcome to this course 🎉&nbsp;</h2>
<p>Thank you for joining this course and I hope you get the best out of it.</p>`),
courseId: newCourse.id,
isPinned: true,
authorId: newMember.id
},
tx
);
return {
course: newCourse,
groupId: newGroup.id,
memberId: newMember.id
};
});
return result;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to create course',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
/**
* Updates a course
* @param courseId Course ID
* @param data Course update data
* @returns Updated course
*/
export async function updateCourse(courseId: string, data: Partial<TCourse>) {
try {
const sanitizedData: Partial<TCourse> = {
...data,
description: sanitizeOptionalHtml(data.description),
overview: sanitizeOptionalHtml(data.overview),
metadata: sanitizeCourseMetadata(data.metadata),
certificate: sanitizeCourseCertificate(data.certificate),
logo: data.logo,
slug: data.slug
};
if (data.type !== undefined) {
const currentType = await getCourseTypeById(courseId);
await guardCourseTypeTransition({
courseId,
currentType: currentType ?? null,
nextType: data.type
});
}
if (data.certificate?.requiredExerciseId) {
const ok = await exerciseBelongsToCourse(data.certificate.requiredExerciseId, courseId);
if (!ok) {
throw new AppError('Certification exercise must belong to this course', ErrorCodes.VALIDATION_ERROR, 400);
}
}
const updated = await updateCourseQuery(courseId, sanitizeUnknownStrings(sanitizedData));
if (!updated) {
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
}
const isContentGroupingEnabled = (updated.metadata?.isContentGroupingEnabled ?? DEFAULT_CONTENT_GROUPING) === true;
if (isContentGroupingEnabled) {
const courseWithRelations = await getCourseWithRelations(courseId);
if (courseWithRelations) {
const contentItems = courseWithRelations.contentItems;
const hasSections = contentItems.some((item) => item.type === ContentType.Section);
const hasUngroupedItems = contentItems.some(
(item) => (item.type === ContentType.Lesson || item.type === ContentType.Exercise) && item.sectionId === null
);
if (!hasSections && hasUngroupedItems) {
await db.transaction(async (tx) => {
const [section] = await createCourseSections(
[
{
title: DEFAULT_SECTION_TITLE,
order: 1,
courseId
}
],
tx
);
if (!section) {
throw new AppError('Failed to create course section', ErrorCodes.COURSE_SECTION_NOT_FOUND, 500);
}
await updateLessonsSectionId(courseId, section.id, tx);
await updateExercisesSectionId(courseId, section.id, tx);
});
}
}
}
return updated;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to update course',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
/**
* Soft deletes a course by setting status to 'DELETED'
* @param courseId Course ID
* @returns Deleted course
*/
export async function deleteCourse(courseId: string) {
try {
const deleted = await deleteCourseQuery(courseId);
if (!deleted) {
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
}
return deleted;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to delete course',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
/**
* Gets course progress for a profile
* @param courseId Course ID
* @param profileId Profile ID
* @returns Course progress with counts
*/
export async function getCourseProgress(courseId: string, profileId: string) {
try {
const progress = await getCourseProgressQuery(courseId, profileId);
return progress;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to get course progress',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
/**
* Gets course analytics including student progress, completion rates, and grades
* @param courseId Course ID
* @returns Course analytics data
*/
export async function getCourseAnalytics(courseId: string) {
try {
// Get course with members (no profileId needed for analytics)
const course = await getCourseWithRelations(courseId);
if (!course) {
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
}
// Get all members
const members = course.group?.members || [];
// Filter students and tutors
const students = members.filter((member) => member.roleId === ROLE.STUDENT);
const tutors = members.filter((member) => member.roleId === ROLE.ADMIN || member.roleId === ROLE.TUTOR);
// Get lessons and exercises
const lessons = course.contentItems.filter((item) => item.type === ContentType.Lesson);
const exercises = course.contentItems.filter((item) => item.type === ContentType.Exercise);
// Get student analytics
const studentAnalytics = await Promise.all(
students
.filter((student) => student.profileId !== null)
.map(async (student) => {
try {
const [courseProgress, userExercisesStats, lastLoginDate] = await Promise.all([
getCourseProgressQuery(courseId, student.profileId!),
getUserExercisesStats(courseId, student.profileId!),
getLastLogin(student.profileId!)
]);
if (!courseProgress) {
return null;
}
const completedExercises = userExercisesStats?.filter((exercise) => exercise.isCompleted)?.length || 0;
const totalExercises = userExercisesStats?.length || 0;
const totalEarnedPoints = userExercisesStats?.reduce((sum, exercise) => sum + exercise.score, 0) || 0;
const totalPoints = userExercisesStats?.reduce((sum, exercise) => sum + exercise.totalPoints, 0) || 0;
const averageGrade = calcPercentageWithRounding(totalEarnedPoints, totalPoints);
const lessonsCompleted = courseProgress.lessonsCompleted || 0;
const totalLessons = courseProgress.lessonsCount || 0;
const progressPercentage = calcPercentageWithRounding(lessonsCompleted, totalLessons);
return {
id: student.profileId,
profile: {
fullname: student.profile?.fullname || 'Unknown',
email: student.profile?.email || '',
avatar_url: student.profile?.avatarUrl || ''
},
lessonsCompleted,
totalLessons,
exercisesSubmitted: completedExercises,
totalExercises,
averageGrade,
lastSeen: formatLastSeen(lastLoginDate),
progressPercentage
};
} catch (error) {
console.error('Error getting student overview:', error);
return null;
}
})
);
const validStudentAnalytics = studentAnalytics.filter(
(student): student is NonNullable<typeof student> => student !== null
);
// Calculate aggregated metrics
let lessonCompletionRate = 0;
let exerciseCompletionRate = 0;
let averageGrade = 0;
if (validStudentAnalytics.length > 0) {
lessonCompletionRate = Math.round(
validStudentAnalytics.reduce((sum, student) => sum + student.progressPercentage, 0) /
validStudentAnalytics.length
);
exerciseCompletionRate = Math.round(
validStudentAnalytics.reduce((sum, student) => {
const completionRate =
student.totalExercises > 0 ? (student.exercisesSubmitted / student.totalExercises) * 100 : 0;
return sum + completionRate;
}, 0) / validStudentAnalytics.length
);
averageGrade = Math.round(
validStudentAnalytics.reduce((sum, student) => sum + student.averageGrade, 0) / validStudentAnalytics.length
);
}
return {
totalTutors: tutors.length,
totalStudents: students.length,
totalLessons: lessons.length,
totalExercises: exercises.length,
lessonCompletionRate,
exerciseCompletionRate,
averageGrade,
students: validStudentAnalytics
};
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to get course analytics',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
/**
* Gets user course analytics for a specific course
* @param courseId Course ID
* @param userId User ID (profile ID)
* @returns User course analytics data
*/
export async function getUserCourseAnalytics(courseId: string, userId: string) {
try {
// Get user profile
const profile = await getProfileById(userId);
if (!profile) {
throw new AppError('User profile not found', ErrorCodes.PROFILE_NOT_FOUND, 404);
}
// Get last login
const lastLoginDate = await getLastLogin(userId);
// Fetch user exercises stats, lessons with completion, and course progress
const [userExercisesStats, lessons, courseProgress] = await Promise.all([
getUserExercisesStats(courseId, userId),
getLessonsWithCompletion(courseId, userId),
getProfileCourseProgress(courseId, userId)
]);
if (!userExercisesStats || !lessons || !courseProgress) {
throw new AppError('Failed to fetch course analytics data', ErrorCodes.INTERNAL_ERROR, 500);
}
// Calculate metrics
const totalEarnedPoints = userExercisesStats.reduce((sum, exercise) => sum + exercise.score, 0);
const totalPoints = userExercisesStats.reduce((sum, exercise) => sum + exercise.totalPoints, 0);
const averageGrade = calcPercentageWithRounding(totalEarnedPoints, totalPoints);
const completedLessons = lessons.filter((lesson) => lesson.completed);
const progressPercentage = calcPercentageWithRounding(completedLessons.length, lessons.length);
const completedExercises = userExercisesStats.filter((exercise) => exercise.isCompleted).length;
const totalExercises = courseProgress.exercises_count || 0;
return {
user: {
id: userId,
fullName: profile.fullname || '',
email: profile.email || '',
avatarUrl: profile.avatarUrl || '',
lastSeen: formatLastSeen(lastLoginDate)
},
averageGrade,
userExercisesStats,
totalExercises,
completedExercises,
progressPercentage
};
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError('Failed to get user exercises stats', ErrorCodes.INTERNAL_ERROR, 500);
}
}
export type { CourseContent, CourseContentItem } from './utils';
@@ -1,263 +0,0 @@
import { getCourseById } from '@cio/db/queries/course';
import { getCourseContentItems, type CourseContentItemRow } from '@cio/db/queries/course/content';
import { getCourseOrganizationId } from '@cio/db/queries/tag';
import { getOrganizationById } from '@cio/db/queries/organization';
import type { TCourse, TOrganization } from '@cio/db/types';
import { generateSlug } from '@cio/utils/functions';
import { ContentType } from '@cio/utils/constants';
import type { TCourseLandingPageUpdate } from '@cio/utils/validation/course';
import { AppError, ErrorCodes } from '@api/utils/errors';
import { updateCourse } from './course';
import { buildCourseBaseUrl, ensureCourseSlug, generateUniqueCourseSlug } from './landing-page';
export type CourseGoLiveIssue = {
code: string;
message: string;
target?: string;
};
export type CourseGoLiveReadiness = {
ready: boolean;
blockers: CourseGoLiveIssue[];
warnings: CourseGoLiveIssue[];
suggestedFixes: {
slug?: string;
landingPage?: TCourseLandingPageUpdate;
};
courseUrl?: string;
};
type CourseReadinessInput = {
course: Pick<
TCourse,
'id' | 'title' | 'description' | 'overview' | 'slug' | 'logo' | 'bannerImage' | 'metadata' | 'type' | 'cost'
>;
contentItems: CourseContentItemRow[];
suggestedSlug?: string;
organization?: Pick<TOrganization, 'customDomain' | 'isCustomDomainVerified' | 'siteName'> | null;
};
const PLACEHOLDER_PATTERNS = [
/\[edit me\]/i,
/^untitled\b/i,
/^new course$/i,
/^course title$/i,
/^course description$/i,
/welcome to this amazing course/i
];
function normalizeReadableText(value: string | null | undefined): string {
return (value ?? '')
.replace(/<[^>]*>/g, ' ')
.replace(/&nbsp;|&#160;/gi, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function hasMeaningfulText(value: string | null | undefined): boolean {
const normalized = normalizeReadableText(value);
if (normalized.length < 3) {
return false;
}
return !PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(normalized));
}
function buildIssue(code: string, message: string, target?: string): CourseGoLiveIssue {
return target ? { code, message, target } : { code, message };
}
function hasLessonContent(item: CourseContentItemRow): boolean {
return Boolean(
item.hasNoteContent || item.hasSlideContent || (item.videosCount ?? 0) > 0 || (item.documentsCount ?? 0) > 0
);
}
function buildCourseUrl(
organization: CourseReadinessInput['organization'],
slug: string | null | undefined
): string | undefined {
if (!organization || !slug) {
return undefined;
}
return `${buildCourseBaseUrl(organization)}/course/${encodeURIComponent(slug)}`;
}
export function evaluateCourseGoLiveReadiness(input: CourseReadinessInput): CourseGoLiveReadiness {
const { course, contentItems, organization, suggestedSlug } = input;
const blockers: CourseGoLiveIssue[] = [];
const warnings: CourseGoLiveIssue[] = [];
const landingPageFixes: TCourseLandingPageUpdate = {};
const metadataFixes: NonNullable<TCourseLandingPageUpdate['metadata']> = {};
if (!hasMeaningfulText(course.title)) {
blockers.push(buildIssue('COURSE_TITLE_MISSING', 'Add a clear course title.', 'course.title'));
}
if (!hasMeaningfulText(course.description)) {
blockers.push(buildIssue('COURSE_DESCRIPTION_MISSING', 'Add a clear course description.', 'course.description'));
}
if (!course.slug && !suggestedSlug) {
blockers.push(buildIssue('COURSE_SLUG_MISSING', 'Generate a public course slug.', 'course.slug'));
}
if (!hasMeaningfulText(course.overview)) {
blockers.push(buildIssue('LANDING_OVERVIEW_MISSING', 'Add a landing-page overview.', 'course.overview'));
landingPageFixes.overview = '';
}
if (!hasMeaningfulText(course.metadata?.description)) {
blockers.push(
buildIssue('LANDING_DESCRIPTION_MISSING', 'Add a landing-page description.', 'course.metadata.description')
);
metadataFixes.description = '';
}
if (!hasMeaningfulText(course.metadata?.goals)) {
blockers.push(
buildIssue('LANDING_GOALS_MISSING', 'Add course goals for the landing page.', 'course.metadata.goals')
);
metadataFixes.goals = '';
}
if (!hasMeaningfulText(course.metadata?.requirements)) {
blockers.push(
buildIssue(
'LANDING_REQUIREMENTS_MISSING',
'Add course requirements for the landing page.',
'course.metadata.requirements'
)
);
metadataFixes.requirements = '';
}
if (!course.logo && !course.bannerImage) {
blockers.push(buildIssue('LANDING_IMAGE_MISSING', 'Add a landing-page banner or course image.', 'course.logo'));
landingPageFixes.generateImage = true;
}
const learningItems = contentItems.filter(
(item) => item.type === ContentType.Lesson || item.type === ContentType.Exercise
);
if (learningItems.length === 0) {
blockers.push(buildIssue('COURSE_CONTENT_MISSING', 'Add at least one lesson or exercise.', 'course.content'));
}
for (const item of contentItems) {
if (item.type === ContentType.Lesson && !hasLessonContent(item)) {
blockers.push(
buildIssue(
'LESSON_CONTENT_EMPTY',
`Add content to lesson "${item.title ?? 'Untitled lesson'}".`,
`lesson:${item.id}`
)
);
}
if (item.type === ContentType.Exercise && (item.questionCount ?? 0) === 0) {
blockers.push(
buildIssue(
'EXERCISE_QUESTIONS_MISSING',
`Add at least one question to exercise "${item.title ?? 'Untitled exercise'}".`,
`exercise:${item.id}`
)
);
}
}
if (!hasMeaningfulText(course.metadata?.instructor?.name)) {
warnings.push(
buildIssue('INSTRUCTOR_MISSING', 'Add instructor information for more trust.', 'course.metadata.instructor')
);
}
if (!course.metadata?.reviews?.some((review) => !review.hide && hasMeaningfulText(review.description))) {
warnings.push(
buildIssue('REVIEWS_MISSING', 'Add testimonials or reviews when available.', 'course.metadata.reviews')
);
}
if ((course.cost ?? 0) > 0 && !hasMeaningfulText(course.metadata?.paymentLink)) {
warnings.push(
buildIssue('PAYMENT_LINK_MISSING', 'Add a payment link for this paid course.', 'course.metadata.paymentLink')
);
}
if (course.type === 'COMPLIANCE') {
warnings.push(
buildIssue('COMPLIANCE_REVIEW_RECOMMENDED', 'Review compliance settings before publishing.', 'course.compliance')
);
}
if (Object.keys(metadataFixes).length > 0) {
landingPageFixes.metadata = metadataFixes;
}
const suggestedFixes: CourseGoLiveReadiness['suggestedFixes'] = {};
if (!course.slug && suggestedSlug) {
suggestedFixes.slug = suggestedSlug;
}
if (Object.keys(landingPageFixes).length > 0) {
suggestedFixes.landingPage = landingPageFixes;
}
return {
ready: blockers.length === 0,
blockers,
warnings,
suggestedFixes,
courseUrl: buildCourseUrl(organization, course.slug ?? suggestedSlug)
};
}
export async function getCourseGoLiveReadiness(courseId: string): Promise<CourseGoLiveReadiness> {
const [course] = await getCourseById(courseId);
if (!course) {
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
}
const organizationId = await getCourseOrganizationId(courseId);
const [contentItems, organization] = await Promise.all([
getCourseContentItems(courseId),
organizationId ? getOrganizationById(organizationId) : Promise.resolve(null)
]);
const suggestedSlug = course.slug
? undefined
: await generateUniqueCourseSlug(generateSlug(course.title, { fallback: 'course' }));
return evaluateCourseGoLiveReadiness({
course,
contentItems,
suggestedSlug,
organization
});
}
export async function publishCourseWhenReady(courseId: string) {
const readiness = await getCourseGoLiveReadiness(courseId);
if (!readiness.ready) {
const blockerList = readiness.blockers.map((blocker) => blocker.message).join(' ');
throw new AppError(`Course is not ready to go live. ${blockerList}`, 'COURSE_GO_LIVE_BLOCKED', 400);
}
const [course] = await getCourseById(courseId);
if (!course) {
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
}
const slug = await ensureCourseSlug(courseId, course.title);
const publishedCourse = await updateCourse(courseId, { slug, isPublished: true });
const publishedReadiness = await getCourseGoLiveReadiness(courseId);
return {
course: publishedCourse,
readiness: publishedReadiness
};
}
+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
});
@@ -1,222 +0,0 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import type { TCourse, TOrganization } from '@db/types';
import type { TCourseLandingPageMetadataUpdate, TCourseLandingPageUpdate } from '@cio/utils/validation/course';
import { env } from '@api/config/env';
import { generateSlug } from '@cio/utils/functions';
import { getCourseById, isCourseSlugTaken, updateCourseSlug } from '@cio/db/queries/course';
import { getCourseOrganizationId } from '@cio/db/queries/tag';
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { getOrganizationById } from '@cio/db/queries/organization';
import { updateCourse } from '@api/services/course/course';
type CourseLandingPageUpdateResult = {
course: TCourse;
courseUrl: string;
bannerImageUrl: string | null;
};
export async function generateUniqueCourseSlug(baseSlug: string): Promise<string> {
let suffix = 0;
let candidate = baseSlug;
while (true) {
const taken = await isCourseSlugTaken(candidate);
if (!taken) {
return candidate;
}
suffix += 1;
candidate = `${baseSlug}-${suffix}`;
}
}
export async function ensureCourseSlug(courseId: string, title: string | null | undefined): Promise<string> {
const [course] = await getCourseById(courseId);
if (course?.slug) {
return course.slug;
}
const baseSlug = generateSlug(title, { fallback: 'course' });
const uniqueSlug = await generateUniqueCourseSlug(baseSlug);
const updated = await updateCourseSlug(courseId, uniqueSlug);
if (!updated) {
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
}
return updated.slug ?? uniqueSlug;
}
export function buildCourseBaseUrl(
organization: Pick<TOrganization, 'customDomain' | 'isCustomDomainVerified' | 'siteName'>
) {
if (organization.customDomain && organization.isCustomDomainVerified) {
return `https://${organization.customDomain}`;
}
return getDashboardBaseUrl(organization.siteName ?? undefined);
}
export async function resolveCourseUrl(organizationId: string, courseId: string, title: string) {
const organization = await getOrganizationById(organizationId);
if (!organization) {
throw new AppError('Organization not found', ErrorCodes.ORG_NOT_FOUND, 404);
}
const slug = await ensureCourseSlug(courseId, title);
return `${buildCourseBaseUrl(organization)}/course/${encodeURIComponent(slug)}`;
}
async function resolveUnsplashBannerImage(courseTitle: string, query?: string) {
if (!env.UNSPLASH_API_KEY) {
return null;
}
const searchQuery = (query?.trim() || courseTitle.trim() || 'education').slice(0, 120);
const response = await fetch(
`https://api.unsplash.com/search/photos?page=1&per_page=15&auto=format&fit=crop&w=2970&q=80&client_id=${env.UNSPLASH_API_KEY}&query=${encodeURIComponent(searchQuery)}`,
{ method: 'GET' }
);
if (!response.ok) {
return null;
}
const data = (await response.json()) as {
results?: Array<{
urls?: {
regular?: string;
};
}>;
};
const photos = data.results ?? [];
if (photos.length === 0) {
return null;
}
const photo = photos[Math.floor(Math.random() * photos.length)];
return photo?.urls?.regular ?? null;
}
async function resolveLandingPageImage(
courseTitle: string,
payload: Pick<TCourseLandingPageUpdate, 'generateImage' | 'imageQuery' | 'imageUrl'>
) {
if (payload.imageUrl) {
return payload.imageUrl;
}
if (!payload.generateImage && !payload.imageQuery) {
return null;
}
try {
return await resolveUnsplashBannerImage(courseTitle, payload.imageQuery);
} catch (error) {
console.error('resolveLandingPageImage error:', error);
return null;
}
}
function mergeLandingPageMetadata(
existing: TCourse['metadata'] | null | undefined,
update: TCourseLandingPageMetadataUpdate | undefined
): TCourse['metadata'] | undefined {
if (!update) {
return existing ?? undefined;
}
const current: NonNullable<TCourse['metadata']> = {
goals: '',
description: '',
requirements: '',
allowNewStudent: true,
...(existing ?? {})
};
return {
...current,
...update,
reward: update.reward
? {
show: update.reward.show ?? current.reward?.show ?? false,
description: update.reward.description ?? current.reward?.description ?? ''
}
: current.reward,
instructor: update.instructor
? {
name: update.instructor.name ?? current.instructor?.name ?? '',
role: update.instructor.role ?? current.instructor?.role ?? '',
coursesNo: update.instructor.coursesNo ?? current.instructor?.coursesNo ?? 0,
description: update.instructor.description ?? current.instructor?.description ?? '',
imgUrl: update.instructor.imgUrl ?? current.instructor?.imgUrl ?? ''
}
: current.instructor,
certificate: update.certificate
? {
templateUrl: update.certificate.templateUrl ?? current.certificate?.templateUrl ?? ''
}
: current.certificate,
sectionDisplay: update.sectionDisplay
? { ...(current.sectionDisplay ?? {}), ...update.sectionDisplay }
: current.sectionDisplay,
reviews: update.reviews ?? current.reviews,
lessonTabsOrder: update.lessonTabsOrder ?? current.lessonTabsOrder
};
}
function normalizeReviews(reviews: NonNullable<TCourse['metadata']>['reviews'] | undefined) {
return reviews?.map((review, index) => ({
id: review.id ?? Date.now() + index,
hide: review.hide ?? false,
name: review.name,
avatar_url: review.avatar_url,
rating: review.rating,
created_at: review.created_at ?? Date.now(),
description: review.description
}));
}
export async function updateCourseLandingPageService(
courseId: string,
payload: TCourseLandingPageUpdate
): Promise<CourseLandingPageUpdateResult> {
const [existingCourse] = await getCourseById(courseId);
if (!existingCourse) {
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
}
const nextTitle = payload.title ?? existingCourse.title;
const metadata = mergeLandingPageMetadata(existingCourse.metadata ?? undefined, payload.metadata);
const imageUrl = await resolveLandingPageImage(nextTitle, payload);
const updatedCourse = await updateCourse(courseId, {
title: payload.title,
description: payload.description,
overview: payload.overview,
cost: payload.cost,
currency: payload.currency,
metadata: metadata
? {
...metadata,
reviews: normalizeReviews(metadata.reviews)
}
: undefined,
...(imageUrl ? { logo: imageUrl, bannerImage: imageUrl } : {})
});
const organizationId = await getCourseOrganizationId(courseId);
if (!organizationId) {
throw new AppError('Course organization not found', ErrorCodes.COURSE_NOT_FOUND, 404);
}
const courseUrl = await resolveCourseUrl(organizationId, courseId, updatedCourse.title);
return {
course: updatedCourse,
courseUrl,
bannerImageUrl: updatedCourse.logo || imageUrl || null
};
}
+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>[] = [];
@@ -1,61 +0,0 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { findNonAutoGradableQuestionsInCourse, getCourseTypeById } from '@cio/db/queries/course';
import { AUTO_GRADABLE_QUESTION_TYPE_IDS, isAutoGradableQuestionTypeId } from '@cio/question-types';
/**
* Throws `QUESTION_TYPE_NOT_ALLOWED_IN_PUBLIC_COURSE` when a non-auto-gradable question
* type is being attached to an exercise that belongs to a `PUBLIC` course.
*
* Accepts the full list of question type ids being created / updated (ignore-null safe).
*/
export async function guardNonAutoGradableQuestionsForCourseType(params: {
courseId: string;
questionTypeIds: Array<number | null | undefined>;
}): Promise<void> {
const typeIds = params.questionTypeIds.filter((id): id is number => typeof id === 'number');
if (typeIds.length === 0) return;
const hasDisallowed = typeIds.some((id) => !isAutoGradableQuestionTypeId(id));
if (!hasDisallowed) return;
const rowType = await getCourseTypeById(params.courseId);
if (!rowType) return;
if (rowType === 'PUBLIC') {
throw new AppError(
'Public courses only support auto-gradable question types',
ErrorCodes.QUESTION_TYPE_NOT_ALLOWED_IN_PUBLIC_COURSE,
400,
'questions'
);
}
}
/**
* When converting a course to `PUBLIC`, enumerate any questions whose type is not
* auto-gradable. Throws `PUBLIC_COURSE_CONVERSION_BLOCKED` with the offending list
* in `message`; callers should surface the list to the creator so they can fix them.
*/
export async function guardCourseTypeTransition(params: {
courseId: string;
currentType: string | null;
nextType: string | null | undefined;
}): Promise<void> {
if (!params.nextType) return;
if (params.nextType !== 'PUBLIC') return;
if (params.currentType === 'PUBLIC') return;
const offenders = await findNonAutoGradableQuestionsInCourse(params.courseId, AUTO_GRADABLE_QUESTION_TYPE_IDS);
if (offenders.length === 0) return;
const preview = offenders.slice(0, 5).map((item) => `"${item.questionTitle}" in "${item.exerciseTitle}"`);
const more = offenders.length > 5 ? ` (+${offenders.length - 5} more)` : '';
throw new AppError(
`Remove or replace these non-auto-gradable questions before converting to Public: ${preview.join('; ')}${more}`,
ErrorCodes.PUBLIC_COURSE_CONVERSION_BLOCKED,
400,
'type'
);
}
+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];
-235
View File
@@ -1,235 +0,0 @@
import type { TCourseSection } from '@cio/db/types';
import {
createCourseSections,
deleteCourseSection,
getCourseSectionById,
getCourseSectionsByCourseId,
updateCourseSection,
updateUngroupedLessonsSectionId
} from '@cio/db/queries/course';
import { AppError, ErrorCodes } from '@api/utils/errors';
import type {
TCourseSectionCreate,
TCourseSectionPromoteUngrouped,
TCourseSectionReorder,
TCourseSectionUpdate
} from '@cio/utils/validation/course/section';
import { updateUngroupedExercisesSectionId } from '@cio/db/queries/exercise/exercise';
import { db } from '@cio/db/drizzle';
/**
* Creates a new course section
* @param courseId Course ID
* @param data Section creation data
* @returns Created section
*/
export async function createCourseSection(courseId: string, data: TCourseSectionCreate): Promise<TCourseSection> {
try {
const sectionData = {
...data,
courseId
};
const [section] = await createCourseSections([sectionData]);
if (!section) {
throw new AppError('Failed to create course section', ErrorCodes.INTERNAL_ERROR, 500);
}
return section;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to create course section',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
/**
* Promotes synthetic "Ungrouped" content into a real persisted section.
* Creates a section and reassigns all ungrouped lessons/exercises to it transactionally.
*/
export async function promoteUngroupedSection(
courseId: string,
data: TCourseSectionPromoteUngrouped
): Promise<{ section: TCourseSection; movedLessons: number; movedExercises: number }> {
try {
const existingSections = await getCourseSectionsByCourseId(courseId);
const nextOrder = Math.max(...existingSections.map((section) => section.order ?? 0), 0) + 1;
return db.transaction(async (tx) => {
const [section] = await createCourseSections(
[
{
title: data.title,
courseId,
order: nextOrder
}
],
tx
);
if (!section) {
throw new AppError('Failed to create course section', ErrorCodes.INTERNAL_ERROR, 500);
}
const [movedLessons, movedExercises] = await Promise.all([
updateUngroupedLessonsSectionId(courseId, section.id, tx),
updateUngroupedExercisesSectionId(courseId, section.id, tx)
]);
if (movedLessons + movedExercises === 0) {
throw new AppError('No ungrouped content found', ErrorCodes.VALIDATION_ERROR, 400);
}
return {
section,
movedLessons,
movedExercises
};
});
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to promote ungrouped content',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
/**
* Gets a course section by ID
* @param sectionId Section ID
* @returns Section or throws error if not found
*/
export async function getCourseSection(sectionId: string): Promise<TCourseSection> {
try {
const section = await getCourseSectionById(sectionId);
if (!section) {
throw new AppError('Course section not found', ErrorCodes.COURSE_SECTION_NOT_FOUND, 404);
}
return section;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to fetch course section',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
/**
* Updates a course section
* @param sectionId Section ID
* @param data Partial section update data
* @returns Updated section
*/
export async function updateCourseSectionService(
sectionId: string,
data: TCourseSectionUpdate
): Promise<TCourseSection> {
try {
const section = await getCourseSectionById(sectionId);
if (!section) {
throw new AppError('Course section not found', ErrorCodes.COURSE_SECTION_NOT_FOUND, 404);
}
const updated = await updateCourseSection(sectionId, data);
if (!updated) {
throw new AppError('Failed to update course section', ErrorCodes.INTERNAL_ERROR, 500);
}
return updated;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to update course section',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
/**
* Deletes a course section
* @param sectionId Section ID
* @returns Deleted section
*/
export async function deleteCourseSectionService(sectionId: string): Promise<TCourseSection> {
try {
const section = await getCourseSectionById(sectionId);
if (!section) {
throw new AppError('Course section not found', ErrorCodes.COURSE_SECTION_NOT_FOUND, 404);
}
const deleted = await deleteCourseSection(sectionId);
if (!deleted) {
throw new AppError('Failed to delete course section', ErrorCodes.INTERNAL_ERROR, 500);
}
return deleted;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to delete course section',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
/**
* Lists course sections for a course
* @param courseId Course ID
* @returns Array of sections
*/
export async function listCourseSections(courseId: string): Promise<TCourseSection[]> {
try {
return await getCourseSectionsByCourseId(courseId);
} catch (error) {
throw new AppError(
error instanceof Error ? error.message : 'Failed to list course sections',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
/**
* Reorders course sections
* @param sections Array of section IDs and orders
* @returns Updated sections
*/
export async function reorderCourseSections(sections: TCourseSectionReorder['sections']): Promise<TCourseSection[]> {
try {
const updatePromises = sections.map(({ id, order }) => updateCourseSection(id, { order }));
const updated = await Promise.all(updatePromises);
return updated.filter((section): section is TCourseSection => section !== null);
} catch (error) {
throw new AppError(
error instanceof Error ? error.message : 'Failed to reorder course sections',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
-42
View File
@@ -1,42 +0,0 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { getTakenItemSlugs } from '@cio/db/queries/course';
import { SLUG_REGEX, resolveSlugCollision, slugifyTitle } from '@cio/utils/validation';
/**
* Resolve an item (lesson or exercise) slug for a given course:
* - If `requestedSlug` is provided, validate it and reject if already taken (excluding `ignoreItemId`).
* - Otherwise derive a slug from `title` and append `-2`, `-3`, ... until free.
*
* Uniqueness is enforced across the union of `lesson.slug` and `exercise.slug` per course.
*/
export async function resolveItemSlug(params: {
courseId: string;
title: string;
requestedSlug?: string;
ignoreItemSlug?: string;
}): Promise<string> {
const { courseId, title, requestedSlug, ignoreItemSlug } = params;
const takenSlugs = await getTakenItemSlugs(courseId);
if (ignoreItemSlug) {
takenSlugs.delete(ignoreItemSlug);
}
if (requestedSlug) {
if (!SLUG_REGEX.test(requestedSlug)) {
throw new AppError(
'Slug must be lowercase letters, numbers, and single dashes',
ErrorCodes.VALIDATION_ERROR,
400,
'slug'
);
}
if (takenSlugs.has(requestedSlug)) {
throw new AppError('Slug is already in use in this course', ErrorCodes.SLUG_IN_USE, 409, 'slug');
}
return requestedSlug;
}
const base = slugifyTitle(title);
return resolveSlugCollision(base, takenSlugs);
}
+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';
-903
View File
@@ -1,903 +0,0 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { sanitizeHtml, sanitizeOptionalHtml, sanitizeUnknownStrings } from '@api/utils/sanitize-html';
import type { TExercise, TExerciseTemplate, TNewOption, TNewQuestion } from '@cio/db/types';
import type { TExerciseCreate, TExerciseUpdate } from '@cio/utils/validation/exercise';
import { getLessonById } from '@cio/db/queries/lesson';
import {
createExercises,
syncOptionIdSequence,
syncQuestionIdSequence,
createOptions,
createQuestions,
deleteExercise,
deleteOptionsByIds,
deleteQuestionsByIds,
getExerciseById,
getExerciseCompletionByProfile,
getExerciseCompletionsByProfile,
getExerciseSectionsByExerciseId,
getExerciseWithRelationsOptimized,
getExercisesByCourseId,
getLMSExercises,
createExerciseSections,
deleteExerciseSectionsByIds,
updateExerciseSection,
updateExercise,
updateOptions,
updateQuestions
} from '@cio/db/queries/exercise';
import { touchCourseUpdatedAt } from '@cio/db/queries/course';
import { resolveItemSlug } from '@api/services/course/slug';
import { guardNonAutoGradableQuestionsForCourseType } from '@api/services/course/public-course-guard';
import { db } from '@cio/db/drizzle';
import type { DbOrTxClient } from '@cio/db/drizzle';
import {
buildExerciseUpdateFields,
computeExerciseDiff,
createNewQuestionsWithOptions,
fetchQuestionsAndOptions,
transformQuestions,
type QuestionWithRelations
} from './utils';
type ExerciseWithQuestions = TExercise & {
isComplete: boolean;
questions?: QuestionWithRelations[];
sections?: ExerciseSectionWithQuestions[];
};
type ExerciseSectionWithQuestions = {
id: string;
title: string;
description: string | null;
order: number;
colorTheme: string;
afterBehavior: unknown;
questions: QuestionWithRelations[];
};
type ExerciseWithCompletion = TExercise & {
isComplete: boolean;
};
function sanitizeExerciseQuestions(questions: TExerciseCreate['questions'] | TExerciseUpdate['questions']) {
return questions?.map((question) => ({
...question,
question: sanitizeHtml(question.question),
settings: question.settings ? sanitizeUnknownStrings(question.settings) : question.settings,
options: question.options?.map((option) => ({
...option,
label: sanitizeHtml(option.label),
settings: option.settings ? sanitizeUnknownStrings(option.settings) : option.settings
}))
}));
}
function sanitizeExercisePayload(data: TExerciseCreate): TExerciseCreate;
function sanitizeExercisePayload(data: TExerciseUpdate): TExerciseUpdate;
function sanitizeExercisePayload(data: TExerciseCreate | TExerciseUpdate): TExerciseCreate | TExerciseUpdate {
return {
...data,
description: sanitizeOptionalHtml(data.description),
sections:
'sections' in data
? data.sections?.map((section) => ({
...section,
title: sanitizeHtml(section.title),
description: sanitizeOptionalHtml(section.description ?? undefined)
}))
: undefined,
questions: sanitizeExerciseQuestions(data.questions)
};
}
function groupQuestionsBySections(
sections: Awaited<ReturnType<typeof getExerciseSectionsByExerciseId>>,
questions: QuestionWithRelations[]
) {
const questionsBySectionId = new Map<string, QuestionWithRelations[]>();
const unsectionedQuestions: QuestionWithRelations[] = [];
for (const question of questions) {
if (question.exerciseSectionId) {
const sectionQuestions = questionsBySectionId.get(question.exerciseSectionId) ?? [];
sectionQuestions.push(question);
questionsBySectionId.set(question.exerciseSectionId, sectionQuestions);
continue;
}
unsectionedQuestions.push(question);
}
return {
sections: sections.map((section) => ({
id: section.id,
title: section.title,
description: section.description,
order: section.order,
colorTheme: section.colorTheme,
afterBehavior: section.afterBehavior,
questions: questionsBySectionId.get(section.id) ?? []
})),
unsectionedQuestions
};
}
async function syncExerciseSections(
exerciseId: string,
sections: NonNullable<TExerciseUpdate['sections']>,
txClient: DbOrTxClient
) {
const currentSections = await getExerciseSectionsByExerciseId(exerciseId, txClient);
const currentSectionIds = new Set(currentSections.map((section) => section.id));
const incomingSectionIds = new Set(sections.map((section) => section.id).filter((id): id is string => !!id));
for (const section of sections) {
if (section.afterBehavior.action !== 'go_to_section') continue;
if (!incomingSectionIds.has(section.afterBehavior.exerciseSectionId)) {
throw new AppError('Section routing target must exist in this exercise', ErrorCodes.VALIDATION_ERROR, 400);
}
}
const sectionsToDelete = currentSections
.filter((section) => !incomingSectionIds.has(section.id))
.map((section) => section.id);
if (sectionsToDelete.length > 0) {
await deleteExerciseSectionsByIds(sectionsToDelete, txClient);
}
const sectionsToCreate = sections
.filter((section) => !section.id || !currentSectionIds.has(section.id))
.map((section) => ({
id: section.id,
exerciseId,
title: section.title,
description: section.description ?? null,
order: section.order,
colorTheme: section.colorTheme,
afterBehavior: section.afterBehavior
}));
if (sectionsToCreate.length > 0) {
await createExerciseSections(sectionsToCreate, txClient);
}
await Promise.all(
sections
.filter((section) => section.id && currentSectionIds.has(section.id))
.map((section) =>
updateExerciseSection(
section.id!,
{
title: section.title,
description: section.description ?? null,
order: section.order,
colorTheme: section.colorTheme,
afterBehavior: section.afterBehavior
},
txClient
)
)
);
}
function validateSectionQuestionAssignments(data: TExerciseUpdate) {
if (!data.sections || data.sections.length === 0 || !data.questions) return;
const sectionIds = new Set(data.sections.map((section) => section.id).filter((id): id is string => !!id));
const unassignedQuestion = data.questions.find(
(question) => !question.deletedAt && (!question.exerciseSectionId || !sectionIds.has(question.exerciseSectionId))
);
if (unassignedQuestion) {
throw new AppError(
'All active questions must be assigned to an exercise section',
ErrorCodes.VALIDATION_ERROR,
400
);
}
}
async function resolveExerciseCourseId(exercise: TExercise): Promise<string | null> {
if (exercise.courseId) {
return exercise.courseId;
}
if (!exercise.lessonId) {
return null;
}
const lesson = await getLessonById(exercise.lessonId);
return lesson?.courseId ?? null;
}
/**
* Creates a new exercise with optional questions and options
* @param data Exercise creation data
* @returns Created exercise
*/
export async function createExercise(data: TExerciseCreate): Promise<TExercise> {
try {
const sanitizedData = sanitizeExercisePayload(data);
await guardNonAutoGradableQuestionsForCourseType({
courseId: sanitizedData.courseId,
questionTypeIds: (sanitizedData.questions ?? []).map((question) => question.questionTypeId)
});
const slug = await resolveItemSlug({
courseId: sanitizedData.courseId,
title: sanitizedData.title,
requestedSlug: sanitizedData.slug
});
const exerciseData = {
title: sanitizedData.title,
description: sanitizedData.description ?? null,
lessonId: sanitizedData.lessonId || null,
courseId: sanitizedData.courseId,
sectionId: sanitizedData.sectionId || null,
order: sanitizedData.order ?? null,
dueBy: sanitizedData.dueBy || null,
slug
};
const [exercise] = await createExercises([exerciseData]);
if (!exercise) {
throw new AppError('Failed to create exercise', ErrorCodes.INTERNAL_ERROR, 500);
}
// Create questions and options if provided
if (sanitizedData.questions && sanitizedData.questions.length > 0) {
await db.transaction(async (tx) => {
const txClient = tx as DbOrTxClient;
const questionsData: TNewQuestion[] = sanitizedData.questions!.map((q) => ({
exerciseId: exercise.id,
title: q.question,
questionTypeId: q.questionTypeId || 1,
points: q.points || 0,
order: typeof q.order === 'number' ? q.order : undefined,
settings: q.settings ?? {}
}));
const questions = await createQuestions(questionsData, txClient);
const hasOptions = sanitizedData.questions!.some((question) => (question.options?.length ?? 0) > 0);
if (hasOptions) {
await syncOptionIdSequence(txClient);
}
// Create options for each question
for (const [index, questionData] of sanitizedData.questions!.entries()) {
const question = questions[index];
if (!question) continue;
const optionsData: TNewOption[] = (questionData.options ?? []).map((opt) => ({
questionId: question.id,
label: opt.label,
isCorrect: opt.isCorrect,
settings: opt.settings ?? {}
}));
await createOptions(optionsData, txClient);
}
});
}
// Fetch the complete exercise with questions and options
const result = await getExercise(exercise.id);
if (sanitizedData.courseId) {
await touchCourseUpdatedAt(sanitizedData.courseId);
}
return result;
} catch (error) {
console.error('createExercise error:', error);
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to create exercise',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
/**
* Gets an exercise by ID with questions and options
* @param exerciseId Exercise ID
* @returns Exercise with questions and options
*/
export async function getExercise(
exerciseId: string,
dbClient?: DbOrTxClient,
profileId?: string
): Promise<ExerciseWithQuestions> {
try {
// Optimized: Fetch exercise + questions + questionTypes + options in one JOIN query
const { exercise, questions } = await getExerciseWithRelationsOptimized(exerciseId, dbClient);
const isComplete = profileId ? await getExerciseCompletionByProfile(exerciseId, profileId, dbClient) : false;
// Build questionTypeMap from questions (already fetched with questionType)
const questionTypeMap = new Map(questions.map((q) => [q.questionType.id, q.questionType]));
// Extract options from questions and transform
const allOptions = questions.flatMap((q) => q.options || []);
// Transform questions (strip questionType and options since they're handled in transform)
const questionsForTransform = questions.map(({ questionType, options, ...q }) => q);
const questionsWithOptions = transformQuestions(questionsForTransform, allOptions, questionTypeMap);
const sections = await getExerciseSectionsByExerciseId(exerciseId, dbClient);
const sectionGroups = groupQuestionsBySections(sections, questionsWithOptions);
return {
...exercise,
isComplete,
questions: questionsWithOptions,
sections: sectionGroups.sections
};
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to fetch exercise',
ErrorCodes.EXERCISE_FETCH_FAILED,
500
);
}
}
/**
* Updates an exercise with questions and options.
* Only updates what has changed by querying the current state first and computing a diff.
* @param exerciseId Exercise ID
* @param data Partial exercise update data
* @returns Updated exercise
*/
export async function updateExerciseService(exerciseId: string, data: TExerciseUpdate): Promise<TExercise> {
try {
const sanitizedData = sanitizeExercisePayload(data);
validateSectionQuestionAssignments(sanitizedData);
const existing = await getExerciseById(exerciseId);
if (existing?.courseId) {
await guardNonAutoGradableQuestionsForCourseType({
courseId: existing.courseId,
questionTypeIds: (sanitizedData.questions ?? [])
.filter((question) => !('deletedAt' in question && question.deletedAt))
.map((question) => question.questionTypeId)
});
}
if (sanitizedData.slug !== undefined && existing?.courseId) {
sanitizedData.slug = await resolveItemSlug({
courseId: existing.courseId,
title: sanitizedData.title ?? existing.title,
requestedSlug: sanitizedData.slug,
ignoreItemSlug: existing.slug ?? undefined
});
}
const result = await db.transaction(async (tx) => {
const txClient = tx as DbOrTxClient;
// Update exercise basic fields only if they changed
const exerciseUpdate = buildExerciseUpdateFields(sanitizedData);
if (Object.keys(exerciseUpdate).length > 0) {
const updated = await updateExercise(exerciseId, exerciseUpdate, txClient);
if (!updated) {
throw new AppError('Failed to update exercise', ErrorCodes.INTERNAL_ERROR, 500);
}
}
if (sanitizedData.sections !== undefined) {
await syncExerciseSections(exerciseId, sanitizedData.sections, txClient);
}
// Process questions - only update what changed
if (sanitizedData.questions && sanitizedData.questions.length > 0) {
// Query current state from DB
const { questions: currentQuestions, options: currentOptions } = await fetchQuestionsAndOptions(
exerciseId,
txClient
);
// Build current state with options mapped to their questions
const currentQuestionsWithOptions = currentQuestions.map((q) => ({
...q,
options: currentOptions.filter((opt) => opt.questionId === q.id)
}));
// Compute diff between current DB state and incoming data
const diff = computeExerciseDiff(currentQuestionsWithOptions, sanitizedData.questions);
const hasNewQuestions = diff.questions.newQuestions.length > 0;
const hasNewOptions = diff.options.creates.length > 0 || hasNewQuestions;
if (hasNewQuestions) {
await syncQuestionIdSequence(txClient);
}
if (hasNewOptions) {
await syncOptionIdSequence(txClient);
}
// Apply question/option graph changes in dependency order. Question deletes cascade
// options, while new question creation can also create options.
if (diff.questions.deletedIds.length > 0) {
await deleteQuestionsByIds(diff.questions.deletedIds, txClient);
}
if (diff.options.deletedIds.length > 0) {
await deleteOptionsByIds(diff.options.deletedIds, txClient);
}
if (diff.questions.updates.length > 0) {
await updateQuestions(diff.questions.updates, txClient);
}
if (diff.options.updates.length > 0) {
await updateOptions(diff.options.updates, txClient);
}
if (diff.options.creates.length > 0) {
await createOptions(diff.options.creates, txClient);
}
if (hasNewQuestions) {
await createNewQuestionsWithOptions(exerciseId, diff.questions.newQuestions, txClient);
}
}
// Return the updated exercise
return await getExercise(exerciseId, txClient);
});
const courseId = existing?.courseId;
if (courseId) {
await touchCourseUpdatedAt(courseId);
}
return result;
} catch (error) {
console.error('updateExerciseService error:', error);
if (error instanceof AppError) {
throw error;
}
throw new AppError('Failed to update exercise', ErrorCodes.INTERNAL_ERROR, 500);
}
}
export async function updateExerciseSectionMetadataService(
exerciseId: string,
exerciseSectionId: string,
data: { title?: string; description?: string | null }
) {
try {
const sections = await getExerciseSectionsByExerciseId(exerciseId);
const belongs = sections.some((section) => section.id === exerciseSectionId);
if (!belongs) {
throw new AppError(
'That exercise section was not found on this exercise. Call get_exercise_details and use a section id from the sections array.',
ErrorCodes.VALIDATION_ERROR,
404
);
}
const patch: { title?: string; description?: string | null } = {};
if (data.title !== undefined) {
patch.title = sanitizeHtml(data.title);
}
if (data.description !== undefined) {
patch.description = data.description === '' ? null : (sanitizeOptionalHtml(data.description) ?? null);
}
if (Object.keys(patch).length === 0) {
throw new AppError('No fields to update', ErrorCodes.VALIDATION_ERROR, 400);
}
const updated = await updateExerciseSection(exerciseSectionId, patch);
if (!updated) {
throw new AppError('Failed to update exercise section', ErrorCodes.INTERNAL_ERROR, 500);
}
const existing = await getExerciseById(exerciseId);
if (existing?.courseId) {
await touchCourseUpdatedAt(existing.courseId);
}
return {
id: updated.id,
title: updated.title,
description: updated.description ?? null,
exerciseId
};
} catch (error) {
console.error('updateExerciseSectionMetadataService error:', error);
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to update exercise section',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function createExerciseSectionService(
exerciseId: string,
data: {
title: string;
description?: string | null;
order: number;
colorTheme?: 'blue' | 'green' | 'amber' | 'rose' | 'violet' | 'slate';
afterBehavior?:
| { action: 'continue' }
| { action: 'submit' }
| { action: 'go_to_section'; exerciseSectionId: string };
}
) {
try {
const sections = await getExerciseSectionsByExerciseId(exerciseId);
const afterBehavior = data.afterBehavior;
if (afterBehavior?.action === 'go_to_section') {
const targetExists = sections.some((section) => section.id === afterBehavior.exerciseSectionId);
if (!targetExists) {
throw new AppError(
'That exercise section was not found on this exercise. Call get_exercise_details and use a section id from the sections array.',
ErrorCodes.VALIDATION_ERROR,
404
);
}
}
const [created] = await createExerciseSections([
{
exerciseId,
title: data.title,
description: data.description ?? null,
order: data.order,
colorTheme: data.colorTheme ?? 'blue',
afterBehavior: data.afterBehavior ?? { action: 'continue' }
}
]);
if (!created) {
throw new AppError('Failed to create exercise section', ErrorCodes.INTERNAL_ERROR, 500);
}
const existing = await getExerciseById(exerciseId);
if (existing?.courseId) {
await touchCourseUpdatedAt(existing.courseId);
}
return {
id: created.id,
exerciseId: created.exerciseId,
title: created.title,
description: created.description ?? null,
order: created.order,
colorTheme: created.colorTheme,
afterBehavior: created.afterBehavior
};
} catch (error) {
console.error('createExerciseSectionService error:', error);
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to create exercise section',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
/**
* Replaces an exercise's questions and options with the provided payload.
* Existing questions are deleted and recreated from the replacement payload.
*/
export async function replaceExerciseService(exerciseId: string, data: TExerciseUpdate): Promise<TExercise> {
try {
const sanitizedData = sanitizeExercisePayload(data);
const existing = await getExerciseById(exerciseId);
const result = await db.transaction(async (tx) => {
const txClient = tx as DbOrTxClient;
const exerciseUpdate = buildExerciseUpdateFields(sanitizedData);
if (Object.keys(exerciseUpdate).length > 0) {
const updated = await updateExercise(exerciseId, exerciseUpdate, txClient);
if (!updated) {
throw new AppError('Failed to update exercise', ErrorCodes.INTERNAL_ERROR, 500);
}
}
const { questions: currentQuestions } = await fetchQuestionsAndOptions(exerciseId, txClient);
const currentQuestionIds = currentQuestions
.map((question) => question.id)
.filter((id): id is number => id !== undefined);
if (currentQuestionIds.length > 0) {
await deleteQuestionsByIds(currentQuestionIds, txClient);
}
const replacementQuestions = sanitizedData.questions ?? [];
const hasReplacementOptions = replacementQuestions.some((question) => (question.options?.length ?? 0) > 0);
if (replacementQuestions.length > 0) {
await syncQuestionIdSequence(txClient);
if (hasReplacementOptions) {
await syncOptionIdSequence(txClient);
}
await createNewQuestionsWithOptions(exerciseId, replacementQuestions, txClient);
}
return await getExercise(exerciseId, txClient);
});
const courseId = existing?.courseId;
if (courseId) {
await touchCourseUpdatedAt(courseId);
}
return result;
} catch (error) {
console.error('replaceExerciseService error:', error);
if (error instanceof AppError) {
throw error;
}
throw new AppError('Failed to replace exercise', ErrorCodes.INTERNAL_ERROR, 500);
}
}
/**
* Deletes an exercise (cascade deletes questions and options)
* @param exerciseId Exercise ID
* @returns Deleted exercise
*/
export async function deleteExerciseService(exerciseId: string): Promise<TExercise> {
try {
const exercise = await getExerciseById(exerciseId);
if (!exercise) {
throw new AppError('Exercise not found', ErrorCodes.EXERCISE_NOT_FOUND, 404);
}
const deleted = await deleteExercise(exerciseId);
if (!deleted) {
throw new AppError('Failed to delete exercise', ErrorCodes.INTERNAL_ERROR, 500);
}
if (exercise.courseId) {
await touchCourseUpdatedAt(exercise.courseId);
}
return deleted;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to delete exercise',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export async function deleteExerciseForCourseService(courseId: string, exerciseId: string): Promise<TExercise> {
try {
const exercise = await getExerciseById(exerciseId);
if (!exercise) {
throw new AppError('Exercise not found', ErrorCodes.EXERCISE_NOT_FOUND, 404);
}
const exerciseCourseId = await resolveExerciseCourseId(exercise);
if (exerciseCourseId !== courseId) {
throw new AppError('Exercise does not belong to this course', ErrorCodes.EXERCISE_NOT_FOUND, 404);
}
const deleted = await deleteExercise(exerciseId);
if (!deleted) {
throw new AppError('Failed to delete exercise', ErrorCodes.INTERNAL_ERROR, 500);
}
await touchCourseUpdatedAt(courseId);
return deleted;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to delete exercise',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
/**
* Lists exercises for a course
* @param courseId Course ID
* @param lessonId Optional lesson ID filter
* @returns Array of exercises
*/
export async function listExercises(
courseId: string,
filters: { lessonId?: string; sectionId?: string } = {},
profileId?: string
): Promise<ExerciseWithCompletion[]> {
try {
const exercises = await getExercisesByCourseId(courseId, filters);
if (!profileId) {
return exercises.map((exercise) => ({
...exercise,
isComplete: false
}));
}
const completedIds = await getExerciseCompletionsByProfile(
exercises.map((exercise) => exercise.id),
profileId
);
const completedSet = new Set(completedIds);
return exercises.map((exercise) => ({
...exercise,
isComplete: completedSet.has(exercise.id)
}));
} catch (error) {
throw new AppError(
error instanceof Error ? error.message : 'Failed to list exercises',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
/**
* Creates an exercise from a template
* @param courseId Course ID
* @param lessonId Lesson ID
* @param template Exercise template with questionnaire
* @returns Created exercise with questions and options
*/
export async function createExerciseFromTemplate(
courseId: string,
lessonId: string | undefined,
sectionId: string | undefined,
order: number | undefined,
template: TExerciseTemplate
): Promise<TExercise> {
try {
// Create the exercise
const exerciseData: TExerciseCreate = {
title: template.title!,
description: template.description ?? undefined,
lessonId,
sectionId,
order,
courseId
};
const exercise = await createExercise(exerciseData);
// Process the questionnaire to create questions and options
const { questions } = template.questionnaire!;
if (questions && questions.length > 0) {
await db.transaction(async (tx) => {
const txClient = tx as DbOrTxClient;
const hasTemplateOptions = questions.some(
(question) => question.question_type.id !== 3 && (question.options?.length ?? 0) > 0
);
if (hasTemplateOptions) {
await syncOptionIdSequence(txClient);
}
for (const question of questions) {
const { title, question_type, options, order, points } = question;
const questionSettings = sanitizeUnknownStrings(
(question as { settings?: Record<string, unknown> }).settings ?? {}
);
// Create question
const [newQuestion] = await createQuestions(
[
{
exerciseId: exercise.id,
title: sanitizeHtml(title),
questionTypeId: question_type.id,
points: points || 0,
order: order || 0,
settings: questionSettings
}
],
txClient
);
if (!newQuestion) continue;
// Create options if not TEXTAREA type
if (question_type.id !== 3 && options && options.length > 0) {
// Filter out deleted options
// value column is UUID (default gen_random_uuid()); label is the display text only
const optionsData: TNewOption[] = options.map((opt) => ({
questionId: newQuestion.id,
label: sanitizeHtml(opt.label || ''),
isCorrect: opt.is_correct || false,
settings: sanitizeUnknownStrings((opt as { settings?: Record<string, unknown> }).settings ?? {})
}));
await createOptions(optionsData, txClient);
}
}
});
}
// Fetch the complete exercise with questions and options
return await getExercise(exercise.id);
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to create exercise from template',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
/**
* Gets LMS exercises for a student in an organization
* Returns exercises from unlocked lessons in courses where the student is a member
* @param profileId - The profile ID of the student
* @param orgId - The organization ID
* @returns Array of exercises with related data
*/
export async function getLMSExercisesService(profileId: string, orgId: string) {
try {
const exercises = await getLMSExercises(profileId, orgId);
return exercises;
} catch (error) {
throw new AppError(
error instanceof Error ? error.message : 'Failed to fetch LMS exercises',
ErrorCodes.EXERCISE_FETCH_FAILED,
500
);
}
}
+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
-171
View File
@@ -1,171 +0,0 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { sanitizeOptionalHtml } from '@api/utils/sanitize-html';
import type { TLessonLanguage, TLocale, TNewLessonLanguage } from '@db/types';
import {
deleteLessonLanguage,
getLessonLanguageByLessonIdAndLocale,
getLessonLanguagesByLessonId,
updateLessonLanguage,
upsertLessonLanguage
} from '@cio/db/queries/lesson/language';
import { getLessonById } from '@cio/db/queries/lesson/lesson';
import { touchCourseUpdatedAt } from '@cio/db/queries/course';
/**
* Gets all language translations for a lesson
* @param lessonId - The lesson ID
* @returns Array of lesson languages
*/
export async function listLessonLanguages(lessonId: string): Promise<TLessonLanguage[]> {
try {
const languages = await getLessonLanguagesByLessonId(lessonId);
return languages;
} catch (error) {
throw new AppError(
error instanceof Error ? error.message : 'Failed to fetch lesson languages',
ErrorCodes.LESSON_LANGUAGE_FETCH_FAILED,
500
);
}
}
/**
* Gets a single lesson language by locale
* @param lessonId - The lesson ID
* @param locale - The locale
* @returns Lesson language or null if not found
*/
export async function getLessonLanguage(lessonId: string, locale: TLocale): Promise<TLessonLanguage | null> {
try {
const language = await getLessonLanguageByLessonIdAndLocale(lessonId, locale);
return language;
} catch (error) {
throw new AppError(
error instanceof Error ? error.message : 'Failed to fetch lesson language',
ErrorCodes.LESSON_LANGUAGE_FETCH_FAILED,
500
);
}
}
/**
* Creates or updates a lesson language translation
* @param lessonId - The lesson ID
* @param data - Lesson language data
* @returns Created or updated lesson language
*/
export async function upsertLessonLanguageService(
lessonId: string,
data: Omit<TNewLessonLanguage, 'lessonId'>
): Promise<TLessonLanguage> {
try {
// Verify lesson exists
const lesson = await getLessonById(lessonId);
if (!lesson) {
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
}
const language = await upsertLessonLanguage({
...data,
content: sanitizeOptionalHtml(data.content),
lessonId
});
if (lesson.courseId) {
await touchCourseUpdatedAt(lesson.courseId);
}
return language;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to create or update lesson language',
ErrorCodes.LESSON_LANGUAGE_CREATE_FAILED,
500
);
}
}
/**
* Updates a lesson language translation
* @param lessonId - The lesson ID
* @param locale - The locale
* @param data - Lesson language update data
* @returns Updated lesson language
*/
export async function updateLessonLanguageService(
lessonId: string,
locale: TLocale,
data: Partial<TNewLessonLanguage>
): Promise<TLessonLanguage> {
try {
// Verify lesson exists
const lesson = await getLessonById(lessonId);
if (!lesson) {
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
}
const existing = await getLessonLanguageByLessonIdAndLocale(lessonId, locale);
if (!existing) {
throw new AppError('Lesson language not found', ErrorCodes.LESSON_LANGUAGE_NOT_FOUND, 404);
}
const language = await updateLessonLanguage(lessonId, locale, {
...data,
content: sanitizeOptionalHtml(data.content)
});
if (lesson.courseId) {
await touchCourseUpdatedAt(lesson.courseId);
}
return language;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to update lesson language',
ErrorCodes.LESSON_LANGUAGE_UPDATE_FAILED,
500
);
}
}
export async function deleteLessonLanguageService(lessonId: string, locale: TLocale): Promise<TLessonLanguage> {
try {
const lesson = await getLessonById(lessonId);
if (!lesson) {
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
}
const existing = await getLessonLanguageByLessonIdAndLocale(lessonId, locale);
if (!existing) {
throw new AppError('Lesson language not found', ErrorCodes.LESSON_LANGUAGE_NOT_FOUND, 404);
}
const deleted = await deleteLessonLanguage(lessonId, locale);
if (!deleted) {
throw new AppError('Lesson language not found', ErrorCodes.LESSON_LANGUAGE_NOT_FOUND, 404);
}
if (lesson.courseId) {
await touchCourseUpdatedAt(lesson.courseId);
}
return deleted;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to delete lesson language',
ErrorCodes.LESSON_LANGUAGE_DELETE_FAILED,
500
);
}
}
+1 -1
View File
@@ -1 +1 @@
export * from './lesson';
export * from '@cio/core/services/lesson/lesson';
-408
View File
@@ -1,408 +0,0 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { sanitizeHtml } from '@api/utils/sanitize-html';
import type { TLesson, TNewLessonComment, TNewLessonCompletion } from '@cio/db/types';
import type { TLessonCreate, TLessonReorder, TLessonUpdate } from '@cio/utils/validation/lesson';
import {
createLessonComment,
createLessons,
deleteLesson,
deleteLessonComment,
getLessonById,
getLessonCommentsByLessonId,
getLessonCommentsByLessonIdPaginated,
getLessonCompletion,
getLessonVersionHistory,
getLessonsByCourseId,
updateLesson,
updateLessonComment,
upsertLessonCompletion,
type LessonById
} from '@cio/db/queries/lesson';
import { touchCourseUpdatedAt } from '@cio/db/queries/course';
import { enrichLessonWithPresignedUrls } from '@api/utils/lesson-media';
import { resolveItemSlug } from '@api/services/course/slug';
/**
* Creates a new lesson
* @param courseId Course ID
* @param data Lesson creation data
* @returns Created lesson
*/
export async function createLesson(courseId: string, data: TLessonCreate): Promise<TLesson> {
try {
const slug = await resolveItemSlug({
courseId,
title: data.title,
requestedSlug: data.slug
});
const lessonData = {
...data,
courseId,
slug
};
const [lesson] = await createLessons([lessonData]);
if (!lesson) {
throw new AppError('Failed to create lesson', ErrorCodes.INTERNAL_ERROR, 500);
}
await touchCourseUpdatedAt(courseId);
return lesson;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to create lesson',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
/**
* Gets a lesson by ID
* @param lessonId Lesson ID
* @returns Lesson or throws error if not found
*/
export async function getLesson(lessonId: string): Promise<LessonById> {
try {
const lesson = await getLessonById(lessonId);
if (!lesson) {
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
}
return enrichLessonWithPresignedUrls(lesson);
} catch (error) {
if (error instanceof AppError) {
throw error;
}
// Log error with context for debugging
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch lesson';
console.error(`Error fetching lesson ${lessonId}:`, errorMessage, error);
throw new AppError(errorMessage, ErrorCodes.LESSON_FETCH_FAILED, 500);
}
}
/**
* Updates a lesson
* @param lessonId Lesson ID
* @param data Partial lesson update data
* @returns Updated lesson
*/
export async function updateLessonService(lessonId: string, data: TLessonUpdate): Promise<TLesson> {
try {
const payload: TLessonUpdate = { ...data };
if (payload.slug !== undefined) {
const existing = await getLessonById(lessonId);
if (!existing) {
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
}
if (!existing.courseId) {
throw new AppError('Lesson has no course', ErrorCodes.LESSON_NOT_FOUND, 404);
}
payload.slug = await resolveItemSlug({
courseId: existing.courseId,
title: payload.title ?? existing.title,
requestedSlug: payload.slug,
ignoreItemSlug: existing.slug ?? undefined
});
}
const updated = await updateLesson(lessonId, payload);
if (!updated) {
throw new AppError('Failed to update lesson', ErrorCodes.INTERNAL_ERROR, 500);
}
if (updated.courseId) {
await touchCourseUpdatedAt(updated.courseId);
}
return updated;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to update lesson',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
/**
* Deletes a lesson
* @param lessonId Lesson ID
* @returns Deleted lesson
*/
export async function deleteLessonService(lessonId: string): Promise<TLesson> {
try {
const deleted = await deleteLesson(lessonId);
if (!deleted) {
throw new AppError('Failed to delete lesson', ErrorCodes.INTERNAL_ERROR, 500);
}
if (deleted.courseId) {
await touchCourseUpdatedAt(deleted.courseId);
}
return deleted;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to delete lesson',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
/**
* Lists lessons for a course
* @param courseId Course ID
* @param sectionId Optional section ID filter
* @returns Array of lessons
*/
export async function listLessons(courseId: string, sectionId?: string): Promise<TLesson[]> {
try {
const lessons = await getLessonsByCourseId(courseId);
if (sectionId) {
return lessons.filter((lesson) => lesson.sectionId === sectionId);
}
return lessons;
} catch (error) {
throw new AppError(
error instanceof Error ? error.message : 'Failed to list lessons',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
/**
* Reorders lessons
* @param lessons Array of lesson IDs, orders, and optional sectionIds
* @returns Updated lessons
*/
export async function reorderLessons(lessons: TLessonReorder['lessons']): Promise<TLesson[]> {
try {
// Update each lesson's order and optionally sectionId
const updatePromises = lessons.map(({ id, order, sectionId }) =>
updateLesson(id, { order, ...(sectionId !== undefined && { sectionId }) })
);
const updated = await Promise.all(updatePromises);
const validLessons = updated.filter((l): l is TLesson => l !== null);
const courseId = validLessons[0]?.courseId;
if (courseId) {
await touchCourseUpdatedAt(courseId);
}
return validLessons;
} catch (error) {
throw new AppError(
error instanceof Error ? error.message : 'Failed to reorder lessons',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
// Lesson Comment Services
/**
* Gets comments for a lesson
* @param lessonId Lesson ID
* @returns Array of comments
*/
export async function getLessonComments(lessonId: string) {
try {
return getLessonCommentsByLessonId(lessonId);
} catch (error) {
throw new AppError(
error instanceof Error ? error.message : 'Failed to get lesson comments',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
/**
* Gets paginated comments for a lesson
* @param lessonId Lesson ID
* @param options Pagination options (cursor, limit)
* @returns Paginated comments with metadata
*/
export async function getLessonCommentsPaginated(lessonId: string, options: { cursor?: string; limit: number }) {
try {
return getLessonCommentsByLessonIdPaginated(lessonId, options);
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to get paginated lesson comments',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
/**
* Creates a lesson comment
* @param lessonId Lesson ID
* @param groupMemberId Group member ID (author)
* @param comment Comment text
* @returns Created comment
*/
export async function createLessonCommentService(lessonId: string, groupMemberId: string, comment: string) {
try {
const commentData: TNewLessonComment = {
lessonId,
groupmemberId: groupMemberId,
comment: sanitizeHtml(comment)
};
return await createLessonComment(commentData);
} catch (error) {
console.log('error', error);
throw new AppError(
error instanceof Error ? error.message : 'Failed to create lesson comment',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
/**
* Updates a lesson comment
* @param commentId Comment ID
* @param comment Updated comment text
* @returns Updated comment
*/
export async function updateLessonCommentService(commentId: number, comment: string) {
try {
const updated = await updateLessonComment(commentId, sanitizeHtml(comment));
if (!updated) {
throw new AppError('Comment not found', ErrorCodes.LESSON_COMMENT_UPDATE_FAILED, 404);
}
return updated;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to update lesson comment',
ErrorCodes.LESSON_COMMENT_UPDATE_FAILED,
500
);
}
}
/**
* Deletes a lesson comment
* @param commentId Comment ID
* @returns Deleted comment
*/
export async function deleteLessonCommentService(commentId: number) {
try {
const deleted = await deleteLessonComment(commentId);
if (!deleted) {
throw new AppError('Comment not found', ErrorCodes.COMMENT_NOT_FOUND, 404);
}
return deleted;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to delete lesson comment',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
// Lesson Completion Services
/**
* Gets lesson completion status
* @param lessonId Lesson ID
* @param profileId Profile ID
* @returns Completion record or null
*/
export async function getLessonCompletionService(lessonId: string, profileId: string) {
try {
return await getLessonCompletion(lessonId, profileId);
} catch (error) {
throw new AppError(
error instanceof Error ? error.message : 'Failed to get lesson completion',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
/**
* Creates or updates lesson completion
* @param lessonId Lesson ID
* @param profileId Profile ID
* @param isComplete Completion status
* @returns Completion record
*/
export async function upsertLessonCompletionService(lessonId: string, profileId: string, isComplete: boolean) {
try {
const completionData: TNewLessonCompletion = {
lessonId,
profileId,
isComplete
};
return await upsertLessonCompletion(completionData);
} catch (error) {
throw new AppError(
error instanceof Error ? error.message : 'Failed to update lesson completion',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
/**
* Gets lesson version history for a lesson and locale
* @param lessonId Lesson ID
* @param locale Locale
* @param endRange End range for pagination (0-indexed, inclusive)
* @returns Array of lesson version history entries
*/
export async function getLessonHistoryService(lessonId: string, locale: string, endRange: number) {
try {
return getLessonVersionHistory(lessonId, locale, endRange);
} catch (error) {
throw new AppError(
error instanceof Error ? error.message : 'Failed to get lesson history',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
+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';
+10 -4
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) => ({
...group,
tags: tagsByGroupId.get(group.id) ?? []
}));
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
);
}
-148
View File
@@ -1,148 +0,0 @@
import { enrichObjectsWithUrls, extractKeysFromObjects } from '@api/utils/presigned-urls';
import { generateDocumentDownloadPresignedUrls, generateVideoDownloadPresignedUrls } from '@api/utils/s3';
import { getAssetsByIds } from '@cio/db/queries/assets';
import type { LessonById } from '@cio/db/queries/lesson';
import type { TLesson } from '@cio/db/types';
// Extract types from schema
type LessonVideo = NonNullable<TLesson['videos']>[number];
type LessonDocument = NonNullable<TLesson['documents']>[number];
function mapProviderToVideoType(provider: string): LessonVideo['type'] {
if (provider === 'youtube') return 'youtube';
if (provider === 'google_drive') return 'google_drive';
if (provider === 'generic' || provider === 'external_url') return 'generic';
return 'upload';
}
function mapAssetMimeTypeToDocumentType(mimeType: string | null, fallbackType: LessonDocument['type']) {
if (!mimeType) {
return fallbackType;
}
if (mimeType === 'application/pdf') {
return 'pdf';
}
if (mimeType === 'application/msword') {
return 'doc';
}
if (mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
return 'docx';
}
return fallbackType;
}
function applyCanonicalVideoMetadata(
videos: LessonVideo[],
assets: Awaited<ReturnType<typeof getAssetsByIds>>
): LessonVideo[] {
if (assets.length === 0) {
return videos;
}
const assetsById = new Map(assets.map((asset) => [asset.id, asset]));
return videos.map((video) => {
if (!video.assetId) {
return video;
}
const asset = assetsById.get(video.assetId);
if (!asset) {
return video;
}
const mergedMetadata = {
...(video.metadata ?? {}),
title: asset.title ?? video.metadata?.title,
description: asset.description ?? video.metadata?.description,
thumbnailUrl: asset.thumbnailUrl ?? video.metadata?.thumbnailUrl,
duration: asset.durationSeconds ?? video.metadata?.duration,
aspectRatio: asset.aspectRatio ?? video.metadata?.aspectRatio
};
return {
...video,
type: mapProviderToVideoType(asset.provider),
key: asset.storageKey ?? video.key,
link: asset.provider === 'upload' ? video.link : (asset.sourceUrl ?? video.link),
fileName: asset.title ?? video.fileName,
metadata: mergedMetadata
};
});
}
function applyCanonicalDocumentMetadata(
documents: LessonDocument[],
assets: Awaited<ReturnType<typeof getAssetsByIds>>
): LessonDocument[] {
if (assets.length === 0) {
return documents;
}
const assetsById = new Map(assets.map((asset) => [asset.id, asset]));
return documents.map((document) => {
if (!document.assetId) {
return document;
}
const asset = assetsById.get(document.assetId);
if (!asset) {
return document;
}
return {
...document,
type: mapAssetMimeTypeToDocumentType(asset.mimeType, document.type),
key: asset.storageKey ?? document.key,
link: asset.provider === 'upload' ? document.link : (asset.sourceUrl ?? document.link),
name: asset.title ?? document.name,
size: asset.byteSize ?? document.size
};
});
}
/**
* Enriches lesson with presigned URLs for videos and documents
* @param lesson Lesson to enrich
* @returns Lesson with presigned URLs added
*/
export async function enrichLessonWithPresignedUrls(lesson: LessonById): Promise<LessonById> {
// videos and documents are jsonb fields from the database, now properly typed
// Normalize them to arrays safely (handles null/undefined cases)
const videos = lesson.videos || [];
const documents = lesson.documents || [];
const videoAssetIds = videos.map((video) => video.assetId).filter((assetId): assetId is string => Boolean(assetId));
const documentAssetIds = documents
.map((document) => document.assetId)
.filter((assetId): assetId is string => Boolean(assetId));
const assetIds = Array.from(new Set([...videoAssetIds, ...documentAssetIds]));
const canonicalAssets = assetIds.length ? await getAssetsByIds(assetIds) : [];
const canonicalVideos = applyCanonicalVideoMetadata(videos, canonicalAssets);
const canonicalDocuments = applyCanonicalDocumentMetadata(documents, canonicalAssets);
const videoKeys = extractKeysFromObjects(canonicalVideos.filter((video) => video.type === 'upload'));
const docKeys = extractKeysFromObjects(canonicalDocuments);
// Early return if no keys to process
if (videoKeys.length === 0 && docKeys.length === 0) {
return lesson;
}
// Generate presigned URLs in parallel
const [videoUrls, docUrls] = await Promise.all([
generateVideoDownloadPresignedUrls(videoKeys),
generateDocumentDownloadPresignedUrls(docKeys)
]);
return {
...lesson,
videos: enrichObjectsWithUrls<LessonVideo>(canonicalVideos, videoUrls),
documents: enrichObjectsWithUrls<LessonDocument>(canonicalDocuments, docUrls)
};
}
+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>) {

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