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
+56
View File
@@ -0,0 +1,56 @@
# `@cio/core` — shared business services
This package holds the business-logic layer that both `apps/api` (HTTP) and `apps/jobs` (BullMQ worker) need to call. Anything in here is transport-agnostic: no Hono `Context`, no `ContentfulStatusCode`, no `c.json`, no `zValidator` middleware. Just functions that compose database queries, apply business rules, and raise `AppError` on failure.
The name is intentional: this is the **core** of the platform. The HTTP routes in `apps/api` and the worker processors in `apps/jobs` are thin transports over this layer. Anything they share has to live here — otherwise one app ends up depending on the other (`apps/jobs → apps/api` was the original sin that motivated extracting this package).
## What lives here
| Folder | Contents |
| ------------------------------ | --------------------------------------------------------------------------- |
| `src/services/agent` | The durable agent runner, chat-mode tool implementations, planning helpers. |
| `src/services/course` | Course / section / content / landing-page business logic. |
| `src/services/exercise` | Exercise + question CRUD and orchestration. |
| `src/services/lesson` | Lesson read/write helpers. |
| `src/services/assets` | Asset upload, attach, detach, presign — used by both API routes and worker. |
| `src/services/jobs` | BullMQ enqueue helpers (e.g. `startMediaJob`) callable from any service. |
| `src/services/lesson-language` | Localized lesson content helpers. |
| `src/utils` | Storage, presigning, sanitization, tinybird, redis helpers. |
| `src/config` | Env schema (`env`), storage config, dashboard URL helpers. |
## Placement rule
```
┌─────────────────────────────────────────────────────┐
│ Does the function need a Hono Context, or call │
│ `c.json`, or validate a request shape? │
│ │
│ YES → it's a route handler, stays in │
│ `apps/api/src/routes/{domain}/…` │
│ │
│ NO → it's a business service, goes in │
│ `packages/core/src/services/{domain}/…`│
└─────────────────────────────────────────────────────┘
```
If the function only touches the database and has no business logic, it doesn't belong here either — it goes in `packages/db/src/queries/{domain}/`. The split is:
- **Query layer** (`@cio/db/queries`): pure SQL/Drizzle helpers, no business rules.
- **Service layer** (`@cio/core/services`): composes queries, applies rules, throws `AppError`.
- **Route layer** (`apps/api/src/routes`): HTTP transport — auth, validation, status codes, response shapes.
- **Worker layer** (`apps/jobs/src/workers`): BullMQ transport — pulls a job, calls services, persists outcome.
## What does NOT belong here
- HTTP-shaped code (Hono types, response helpers, content-type handling) — keep in `apps/api`.
- BullMQ worker bootstrapping (Redis connection, worker lifecycle) — keep in `apps/jobs`.
- Frontend / Svelte / UI primitives — those live in `apps/dashboard` or `packages/ui`.
- Raw Drizzle query functions with no business logic — those go in `packages/db/queries`.
## Why it isn't called `@cio/services`
`@cio/core` is shorter and reads cleaner at import sites (`from '@cio/core/services/course/content'`). The folder structure makes it obvious that it's a services layer; the package name doesn't need to repeat that.
## History
This package was originally extracted as `@cio/agent-runtime` to hold the durable agent runner and its transitive closure. That closure turned out to be ~80% generic business services that `apps/api` and the worker both need, so the package was renamed to `@cio/core` to match its contents. The agent runner (`services/agent/course-generation-runner.ts`) remains the top-level export for `apps/jobs`, but it's just one of many services living here now.
+61
View File
@@ -0,0 +1,61 @@
{
"name": "@cio/core",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./services/*": {
"types": "./dist/services/*.d.ts",
"default": "./dist/services/*.js"
},
"./services/*/*": {
"types": "./dist/services/*/*.d.ts",
"default": "./dist/services/*/*.js"
},
"./utils/*": {
"types": "./dist/utils/*.d.ts",
"default": "./dist/utils/*.js"
},
"./config/*": {
"types": "./dist/config/*.d.ts",
"default": "./dist/config/*.js"
}
},
"scripts": {
"build": "tsc && tsc-alias && node scripts/restore-bare-specifiers.mjs",
"dev": "tsc --watch",
"format": "prettier --write ."
},
"dependencies": {
"@ai-sdk/openai": "^3.0.52",
"@aws-sdk/client-s3": "^3.388.0",
"@aws-sdk/s3-request-presigner": "^3.675.0",
"@cio/ai-assistant": "workspace:*",
"@cio/db": "workspace:*",
"@cio/jobs": "workspace:*",
"@cio/question-types": "workspace:*",
"@cio/tsconfig": "workspace:*",
"@cio/utils": "workspace:*",
"ai": "^6.0.158",
"axios": "^1.4.0",
"drizzle-orm": "^0.44.7",
"ioredis": "^5.4.1",
"isomorphic-dompurify": "^3.7.1",
"nanoid": "^5.1.5",
"zod": "^4.2.1"
},
"devDependencies": {
"@types/node": "^20.19.0",
"tsc-alias": "^1.8.16",
"tsx": "^4.7.1",
"typescript": "^5.5.2"
}
}
@@ -0,0 +1,49 @@
#!/usr/bin/env node
// After `tsc && tsc-alias`, the emitted .d.ts files reference workspace deps
// through relative node_modules paths like `../../../node_modules/@cio/utils/dist/...`.
// Cross-package consumers (apps/api) then trip TS2742 because they see those
// types as a different copy of @cio/utils. Rewrite those references back to
// bare package specifiers so the .d.ts is portable across the workspace.
import { readdir, readFile, writeFile, stat } from 'node:fs/promises';
import { join } from 'node:path';
const DIST = new URL('../dist/', import.meta.url).pathname;
const PATTERNS = [
// `../../../node_modules/@cio/utils/dist/validation/course/index.js`
// -> `@cio/utils/validation/course`
{
regex: /(['"])(?:\.\.\/)+node_modules\/(@cio\/[a-z-]+)\/dist\/(.*?)(?:\/index)?\.js\1/g,
replace: (_match, q, pkg, subpath) => `${q}${pkg}/${subpath}${q}`
},
// `.../node_modules/@cio/utils/dist/index.js` -> `@cio/utils`
{
regex: /(['"])(?:\.\.\/)+node_modules\/(@cio\/[a-z-]+)\/dist\/index\.js\1/g,
replace: (_match, q, pkg) => `${q}${pkg}${q}`
}
];
async function walk(dir) {
const out = [];
for (const entry of await readdir(dir)) {
const full = join(dir, entry);
const s = await stat(full);
if (s.isDirectory()) out.push(...(await walk(full)));
else if (entry.endsWith('.d.ts')) out.push(full);
}
return out;
}
const files = await walk(DIST);
let touched = 0;
for (const file of files) {
const before = await readFile(file, 'utf8');
let after = before;
for (const { regex, replace } of PATTERNS) after = after.replace(regex, replace);
if (after !== before) {
await writeFile(file, after);
touched += 1;
}
}
console.log(`restore-bare-specifiers: rewrote ${touched}/${files.length} files`);
+48
View File
@@ -0,0 +1,48 @@
import { TENANT_ROOT_DOMAIN, BRAND_ROOT_DOMAIN } from '@cio/utils/constants/domains';
import { env } from './env';
/**
* Minimal org shape consumed by `getDashboardBaseUrl`. Accepts the three
* fields that decide which host the org's URLs live on. All optional so
* callers without an org context can still ask for a sensible default.
*/
export type DashboardOrg = {
siteName?: string | null;
customDomain?: string | null;
isCustomDomainVerified?: boolean | null;
};
/**
* Returns the base URL for the dashboard app, used for invite links, login
* URLs, course URLs, and email-embedded links.
*
* Resolution order (highest precedence first):
* 1. Org's verified `customDomain` → `https://<customDomain>`
* 2. `DASHBOARD_ORIGIN` env var → that value (self-hosted override)
* 3. `NODE_ENV === 'development'` → `http://localhost:5173`
* 4. Org's `siteName` → `https://<siteName>.myclassroomio.com`
* 5. Final fallback → `https://app.classroomio.com`
*
* `customDomain` wins over `DASHBOARD_ORIGIN` because per-org BYOD must
* override the platform-wide self-hosted dashboard host for that org's URLs.
*/
export function getDashboardBaseUrl(org?: DashboardOrg): string {
if (org?.customDomain && org.isCustomDomainVerified) {
return `https://${org.customDomain}`;
}
if (env.DASHBOARD_ORIGIN) {
return env.DASHBOARD_ORIGIN.replace(/\/$/, '');
}
if (env.NODE_ENV === 'development') {
return 'http://localhost:5173';
}
if (org?.siteName) {
return `https://${org.siteName}.${TENANT_ROOT_DOMAIN}`;
}
return `https://app.${BRAND_ROOT_DOMAIN}`;
}
+100
View File
@@ -0,0 +1,100 @@
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
@@ -0,0 +1,136 @@
/**
* 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 './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 };
+13
View File
@@ -0,0 +1,13 @@
/**
* Public surface of `@cio/core`.
*
* Most consumers reach into the package via subpath imports (`./services/*`,
* `./utils/*`, `./config/*` — see `package.json` exports). The top-level
* surface is intentionally narrow: it only re-exports the durable agent
* runner used by the BullMQ worker in `apps/jobs`.
*/
export {
runAgentCourseGenerationJob,
type RunAgentCourseGenerationJobInput
} from './services/agent/course-generation-runner';
@@ -0,0 +1,327 @@
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']);
/**
* The model only needs to identify which template to render. The server pulls
* the form title and field set from the shared registry — keeping the contract
* server-authoritative means the form can never drift from the pedagogy and
* the model can't omit `id`s or invent unsupported field types.
*/
export const askTemplateQuestionsParam = z.object({
templateId: courseTemplateIdParam
});
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')
});
@@ -0,0 +1,171 @@
import { QUESTION_TYPE_IDS } from '@cio/question-types';
/**
* Balances which option position holds the correct answer across a batch of
* AI-generated questions, so an exercise never ends up with "B is always the
* answer". LLMs cannot produce a uniform distribution on instruction alone —
* this enforces it deterministically at persist time.
*
* - RADIO (single correct): each question's correct option is relocated to a
* target position drawn from a balanced-then-shuffled assignment, so the
* correct position is spread as evenly as possible across the batch (and not
* in a predictable A,B,C,D cycle).
* - CHECKBOX (multiple correct): options are shuffled so the correct set does
* not cluster at the top. Round-robin balancing does not apply because the
* number of correct answers varies per question.
*
* Questions are left untouched when reordering would be unsafe or meaningless:
* non-RADIO/CHECKBOX types, fewer than two options, a malformed correct-count,
* position-referential options ("all of the above"), or naturally-ordered
* option sets (numeric values/ranges).
*
* The shuffle is seeded from the batch content, so the result is deterministic
* and unit-testable — no `Math.random`.
*/
const RADIO_ID = QUESTION_TYPE_IDS.RADIO;
const CHECKBOX_ID = QUESTION_TYPE_IDS.CHECKBOX;
export interface BalanceableOption {
label: string;
isCorrect: boolean;
}
type WithOptions = { questionTypeId: number; options: BalanceableOption[] };
// Options whose text refers to other positions ("all of the above", "both A
// and B", …) break if shuffled into the middle, so the question is skipped.
const POSITION_REFERENTIAL = [
/\ball of the (above|below)\b/,
/\bnone of the (above|below)\b/,
/\b(all|none|both|any) of these\b/,
/\bboth\b.*\band\b/,
/^(answers?\s+)?[a-d]\s+and\s+[a-d]\b/,
/^[a-d],\s*[a-d]/
];
function isPositionReferential(label: string): boolean {
const normalized = label.trim().toLowerCase();
return POSITION_REFERENTIAL.some((pattern) => pattern.test(normalized));
}
// "0-10 / 11-20 / 21-30" or "1 / 2 / 3" read better in order — don't shuffle.
function leadingNumber(label: string): number | null {
const match = label
.trim()
.replace(/^[^\d-]+/, '')
.match(/^-?\d+(?:\.\d+)?/);
return match ? Number(match[0]) : null;
}
function isNaturallyOrdered(options: BalanceableOption[]): boolean {
const numbers = options.map((option) => leadingNumber(option.label));
if (numbers.some((value) => value === null)) return false;
const values = numbers as number[];
const ascending = values.every((value, i) => i === 0 || value >= values[i - 1]);
const descending = values.every((value, i) => i === 0 || value <= values[i - 1]);
return ascending || descending;
}
function correctCount(options: BalanceableOption[]): number {
return options.reduce((total, option) => total + (option.isCorrect ? 1 : 0), 0);
}
function isShuffleSafe(question: WithOptions): boolean {
const { options } = question;
if (options.length < 2) return false;
if (options.some((option) => isPositionReferential(option.label))) return false;
if (isNaturallyOrdered(options)) return false;
return true;
}
// FNV-1a hash → mulberry32 PRNG. Seeded from batch content so identical input
// yields an identical (but well-distributed) layout.
function hashSeed(input: string): number {
let hash = 2166136261 >>> 0;
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
function mulberry32(seed: number): () => number {
let state = seed >>> 0;
return () => {
state = (state + 0x6d2b79f5) | 0;
let t = Math.imul(state ^ (state >>> 15), 1 | state);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function shuffleInPlace<T>(items: T[], random: () => number): void {
for (let i = items.length - 1; i > 0; i--) {
const j = Math.floor(random() * (i + 1));
[items[i], items[j]] = [items[j], items[i]];
}
}
// Move the single correct option to `targetIndex`, preserving distractor order.
function relocateCorrect(options: BalanceableOption[], targetIndex: number): BalanceableOption[] {
const correctIndex = options.findIndex((option) => option.isCorrect);
const correct = options[correctIndex];
const rest = options.filter((_, i) => i !== correctIndex);
rest.splice(targetIndex, 0, correct);
return rest;
}
export function balanceCorrectAnswerPositions<Q extends WithOptions>(questions: Q[]): Q[] {
if (questions.length === 0) return questions;
const seedSource = questions.map((question) => question.options.map((option) => option.label).join('|')).join('||');
const random = mulberry32(hashSeed(seedSource));
// RADIO: assign balanced target positions per option-count group so the
// correct index is spread evenly even when questions have different option
// counts. Built before any mutation so consumption of `random` is stable.
const radioTargets = new Map<number, number>();
const radioGroups = new Map<number, number[]>();
questions.forEach((question, index) => {
if (question.questionTypeId !== RADIO_ID) return;
if (!isShuffleSafe(question) || correctCount(question.options) !== 1) return;
const group = radioGroups.get(question.options.length) ?? [];
group.push(index);
radioGroups.set(question.options.length, group);
});
for (const [optionCount, indexes] of radioGroups) {
const targets = indexes.map((_, position) => position % optionCount);
shuffleInPlace(targets, random);
indexes.forEach((questionIndex, position) => radioTargets.set(questionIndex, targets[position]));
}
return questions.map((question, index) => {
if (question.questionTypeId === RADIO_ID && radioTargets.has(index)) {
return { ...question, options: relocateCorrect(question.options, radioTargets.get(index)!) };
}
if (question.questionTypeId === CHECKBOX_ID && isShuffleSafe(question)) {
const correct = correctCount(question.options);
if (correct > 0 && correct < question.options.length) {
const shuffled = [...question.options];
shuffleInPlace(shuffled, random);
return { ...question, options: shuffled };
}
}
return question;
});
}
@@ -0,0 +1,183 @@
import { AppError } from '@cio/utils/errors';
import { getDocumentText } from './document';
import { redis } from '../../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;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,31 @@
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);
}
@@ -0,0 +1,399 @@
import { hostname } from 'node:os';
import { convertToModelMessages, generateText, stepCountIs } from 'ai';
import {
createAgentRunEvent,
getAgentRunById,
getChatConversation,
listAgentRunSteps,
updateAgentRun
} from '@cio/db/queries/agent';
import { getCourseById } from '@cio/db/queries/course';
import { getCourseOrganizationId } from '@cio/db/queries/tag';
import { AgentRole, AIProvider, MAX_STEPS_PER_ROUND, getCourseTemplate, type AgentContext } from '@cio/ai-assistant';
import { createModel, getProviderConfigForProvider } from '@cio/ai-assistant/providers';
import { buildContextMessage, buildSystemPrompt } from '@cio/ai-assistant/prompt';
import { AGENT_MODELS, DEFAULT_PICKER_MODEL_ID, type AgentModelId } from '@cio/utils/agent-models';
import { AppError } from '@cio/utils/errors';
import { isOrgOnPaidPlan, recordTokenUsage } from './usage';
import {
collectDocumentIds,
getActiveCourseTemplateId,
getLatestImplementationPlan,
loadDocumentsText
} from './chat-context';
import { buildAgentTools } from './chat-tools';
import { buildModelContextMessages } from './model-context';
import { sanitizeDanglingToolCalls } from './sanitize-tool-calls';
import { listCourseSections } from '../course/section';
const MAX_WORKER_ROUNDS = 24;
const CONTINUE_IMPLEMENTATION_PROMPT =
'Continue implementing the approved course plan from the durable run checkpoint. Re-read the current course structure before creating anything that might already exist.';
export interface RunAgentCourseGenerationJobInput {
runId: string;
bullmqJobId?: string | null;
workerId?: string;
}
function getWorkerId(inputWorkerId?: string): string {
return inputWorkerId ?? `agent-course-generation:${hostname()}:${process.pid}`;
}
function getErrorPayload(error: unknown): { code: string; message: string; details?: unknown } {
if (error instanceof AppError) {
return { code: error.code, message: error.message };
}
if (error instanceof Error) {
return { code: 'AGENT_RUN_FAILED', message: error.message, details: { stack: error.stack } };
}
return { code: 'AGENT_RUN_FAILED', message: String(error) };
}
function isAgentModelId(value: unknown): value is AgentModelId {
return typeof value === 'string' && value in AGENT_MODELS;
}
function getRunModelId(executionCursor: Record<string, unknown>): AgentModelId {
const modelId = executionCursor.modelId;
return isAgentModelId(modelId) ? modelId : DEFAULT_PICKER_MODEL_ID;
}
function buildRunStateMessage(input: {
phase: string;
executionCursor: Record<string, unknown>;
completedStepCount: number;
failedStepCount: number;
}): string {
return [
'Durable course-generation run state:',
`- phase: ${input.phase}`,
`- execution cursor: ${JSON.stringify(input.executionCursor)}`,
`- completed checkpointed tool calls: ${input.completedStepCount}`,
`- failed checkpointed tool calls: ${input.failedStepCount}`,
'- On retry, never recreate content that is already present in the current course structure or already checkpointed as completed.',
'- Before creating sections, lessons, exercises, or questions, call get_course_structure or the relevant read tool and continue from the last missing item.'
].join('\n');
}
async function assertRunCanContinue(runId: string): Promise<NonNullable<Awaited<ReturnType<typeof getAgentRunById>>>> {
const run = await getAgentRunById(runId);
if (!run) {
throw new AppError('Agent run not found', 'AGENT_RUN_NOT_FOUND', 404);
}
if (run.status === 'completed') {
throw new AppError('Agent run is already completed', 'AGENT_RUN_ALREADY_COMPLETED', 409);
}
if (run.status === 'canceled' || run.cancelRequestedAt) {
throw new AppError('Agent run was canceled', 'AGENT_RUN_CANCELED', 409);
}
return run;
}
export async function runAgentCourseGenerationJob(input: RunAgentCourseGenerationJobInput): Promise<{
runId: string;
status: 'completed' | 'paused' | 'canceled';
rounds: number;
}> {
const workerId = getWorkerId(input.workerId);
const initialRun = await assertRunCanContinue(input.runId);
const courseOrgId = await getCourseOrganizationId(initialRun.courseId);
if (courseOrgId !== initialRun.orgId) {
throw new AppError('Course does not belong to this organization', 'COURSE_ORG_MISMATCH', 403);
}
const [course] = await getCourseById(initialRun.courseId);
if (!course) {
throw new AppError('Course not found', 'COURSE_NOT_FOUND', 404);
}
const startedAt = initialRun.startedAt ?? new Date().toISOString();
await updateAgentRun({
runId: initialRun.id,
userId: initialRun.userId,
status: 'running',
phase: 'implementing',
startedAt,
finishedAt: null,
workerId,
lockedAt: new Date().toISOString(),
lastError: null
});
await createAgentRunEvent({
runId: initialRun.id,
eventType: 'run.started',
message: 'Agent course-generation worker started',
payload: { bullmqJobId: input.bullmqJobId ?? null, workerId }
});
try {
const conversation = initialRun.conversationId
? await getChatConversation(initialRun.conversationId, initialRun.userId)
: null;
const visibleMessages = (conversation?.messages ?? []) as Array<Record<string, unknown>>;
const documentIds = collectDocumentIds(visibleMessages);
const documentText = documentIds.length > 0 ? await loadDocumentsText(documentIds, initialRun.userId) : undefined;
const existingSections = await listCourseSections(initialRun.courseId);
const executionCursor = initialRun.executionCursor ?? {};
const modelId = getRunModelId(executionCursor);
const modelDescriptor = AGENT_MODELS[modelId];
const baseProviderConfig = getProviderConfigForProvider(modelDescriptor.provider as AIProvider);
if (!baseProviderConfig) {
throw new AppError(`AI assistant is not configured for model "${modelId}"`, 'AI_NOT_CONFIGURED', 503);
}
const providerConfig = { ...baseProviderConfig, model: modelDescriptor.backendModelId };
const agentContext: AgentContext = {
orgId: initialRun.orgId,
courseId: initialRun.courseId,
courseTitle: course.title,
courseDescription: course.description || undefined,
userId: initialRun.userId,
role: AgentRole.TEACHER,
locale: 'en',
documentText,
existingSectionCount: existingSections.length
};
const approvedPlan = initialRun.approvedPlan ?? getLatestImplementationPlan(visibleMessages);
const activeTemplateId = getActiveCourseTemplateId(visibleMessages);
const activeTemplate = activeTemplateId ? getCourseTemplate(activeTemplateId) : undefined;
const systemPrompt = buildSystemPrompt(agentContext);
const contextMessageText = buildContextMessage(agentContext, {
template: activeTemplate,
approvedPlan
});
const steps = await listAgentRunSteps(initialRun.id);
const runStateText = buildRunStateMessage({
phase: initialRun.phase,
executionCursor,
completedStepCount: steps.filter((step) => step.status === 'completed').length,
failedStepCount: steps.filter((step) => step.status === 'failed').length
});
const contextManaged = await buildModelContextMessages({
conversationId: initialRun.conversationId ?? undefined,
courseId: initialRun.courseId,
userId: initialRun.userId,
messages: visibleMessages as any
});
const convertedMessages = sanitizeDanglingToolCalls(await convertToModelMessages(contextManaged.messages as any));
const modelMessages: any[] = [
{ role: 'user' as const, content: [{ type: 'text' as const, text: runStateText }] },
...(contextMessageText.length > 0
? [{ role: 'user' as const, content: [{ type: 'text' as const, text: contextMessageText }] }]
: []),
...convertedMessages
];
modelMessages.push({
role: 'user',
content: [{ type: 'text', text: CONTINUE_IMPLEMENTATION_PROMPT }]
});
const model = createModel(providerConfig);
const isOrgPaid = await isOrgOnPaidPlan(initialRun.orgId);
const agentTools = buildAgentTools(initialRun.orgId, initialRun.userId, initialRun.courseId, visibleMessages, {
runId: initialRun.id,
isOrgOnPaidPlan: isOrgPaid
});
for (let roundIndex = 0; roundIndex < MAX_WORKER_ROUNDS; roundIndex += 1) {
const latestRun = await assertRunCanContinue(initialRun.id);
if (latestRun.queuedInstructions.length > 0) {
const instructionText = latestRun.queuedInstructions.map((instruction) => `- ${instruction.text}`).join('\n');
modelMessages.push({
role: 'user',
content: [
{ type: 'text', text: `Teacher instructions queued while this run was active:\n${instructionText}` }
]
});
await updateAgentRun({
runId: latestRun.id,
userId: latestRun.userId,
queuedInstructions: []
});
await createAgentRunEvent({
runId: latestRun.id,
eventType: 'instruction.applied',
message: 'Queued teacher instructions applied',
payload: { count: latestRun.queuedInstructions.length }
});
}
await updateAgentRun({
runId: initialRun.id,
userId: initialRun.userId,
status: 'running',
phase: 'implementing',
progressPercent: Math.min(95, Math.round((roundIndex / MAX_WORKER_ROUNDS) * 100)),
lockedAt: new Date().toISOString(),
executionCursor: {
...latestRun.executionCursor,
modelId,
workerRound: roundIndex,
bullmqJobId: input.bullmqJobId ?? null
}
});
const result = await generateText({
model,
maxRetries: 2,
system: systemPrompt,
messages: modelMessages,
tools: agentTools,
stopWhen: stepCountIs(MAX_STEPS_PER_ROUND)
});
if (result.usage) {
await recordTokenUsage(
initialRun.orgId,
initialRun.userId,
initialRun.courseId,
{
promptTokens: result.usage.inputTokens ?? 0,
completionTokens: result.usage.outputTokens ?? 0,
totalTokens: (result.usage.inputTokens ?? 0) + (result.usage.outputTokens ?? 0)
},
providerConfig.model || providerConfig.provider
);
}
modelMessages.push(...result.response.messages);
await createAgentRunEvent({
runId: initialRun.id,
eventType: 'round.completed',
message: 'Agent generation round completed',
payload: {
round: roundIndex + 1,
finishReason: result.finishReason,
stepCount: result.steps.length,
textPreview: result.text.slice(0, 500)
}
});
if (result.steps.length < MAX_STEPS_PER_ROUND) {
await updateAgentRun({
runId: initialRun.id,
userId: initialRun.userId,
status: 'completed',
phase: 'completed',
progressPercent: 100,
currentStepKey: null,
finishedAt: new Date().toISOString(),
workerId: null,
lockedAt: null,
executionCursor: {
...latestRun.executionCursor,
modelId,
workerRound: roundIndex + 1,
finishReason: result.finishReason,
finalText: result.text.slice(0, 4_000)
}
});
await createAgentRunEvent({
runId: initialRun.id,
eventType: 'run.completed',
message: 'Agent course-generation run completed',
payload: { rounds: roundIndex + 1 }
});
return { runId: initialRun.id, status: 'completed', rounds: roundIndex + 1 };
}
modelMessages.push({
role: 'user',
content: [{ type: 'text', text: CONTINUE_IMPLEMENTATION_PROMPT }]
});
}
await updateAgentRun({
runId: initialRun.id,
userId: initialRun.userId,
status: 'paused',
phase: 'paused',
currentStepKey: null,
workerId: null,
lockedAt: null,
executionCursor: {
...initialRun.executionCursor,
modelId,
pauseReason: 'max_worker_rounds_reached'
}
});
await createAgentRunEvent({
runId: initialRun.id,
eventType: 'run.paused',
message: 'Run paused after reaching the worker round limit',
payload: { maxWorkerRounds: MAX_WORKER_ROUNDS }
});
return { runId: initialRun.id, status: 'paused', rounds: MAX_WORKER_ROUNDS };
} catch (error) {
const errorPayload = getErrorPayload(error);
if (errorPayload.code === 'AGENT_RUN_CANCELED') {
await updateAgentRun({
runId: initialRun.id,
userId: initialRun.userId,
status: 'canceled',
phase: 'canceled',
currentStepKey: null,
workerId: null,
lockedAt: null,
finishedAt: new Date().toISOString(),
lastError: null
});
await createAgentRunEvent({
runId: initialRun.id,
eventType: 'run.canceled',
message: 'Run canceled before the next checkpoint',
payload: {}
});
return { runId: initialRun.id, status: 'canceled', rounds: 0 };
}
await updateAgentRun({
runId: initialRun.id,
userId: initialRun.userId,
status: 'failed',
phase: 'failed',
currentStepKey: null,
workerId: null,
lockedAt: null,
finishedAt: new Date().toISOString(),
lastError: errorPayload
});
await createAgentRunEvent({
runId: initialRun.id,
eventType: 'run.failed',
message: 'Agent course-generation run failed',
payload: { error: errorPayload }
});
throw error;
}
}
@@ -0,0 +1,231 @@
import { nanoid } from 'nanoid';
import { AppError } from '@cio/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 '../../utils/redis-keys';
import { trackAgentEvent, AgentEvent } from '../../utils/tinybird';
import type { RedisClient } from '../../utils/redis/redis';
import { createChatDocument, getChatDocument } from '@cio/db/queries/agent';
import { generateFileKey } from '../../utils/upload';
import { uploadToS3 } from '../../utils/s3';
import { getStorageConfig } from '../../config/storage';
import { createAssetFromUploadService } from '../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);
}
}
@@ -0,0 +1,343 @@
import { createHash } from 'node:crypto';
import { isIPv4, isIPv6 } from 'node:net';
import { AppError } from '@cio/utils/errors';
import { env } from '../../config/env';
import { redis, logRedisUnavailableOnce } from '../../utils/redis/redis';
import { isOrgOnPaidPlan } from './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
};
}
@@ -0,0 +1,45 @@
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function stripLeadingLessonTitle(content: string, lessonTitle: string): string {
const normalizedTitle = lessonTitle.trim();
if (!normalizedTitle) {
return content;
}
const escapedTitle = escapeRegExp(normalizedTitle);
const patterns = [
new RegExp(`^\\s*<h[1-6][^>]*>\\s*${escapedTitle}\\s*</h[1-6]>\\s*`, 'i'),
new RegExp(
`^\\s*<p[^>]*>\\s*(?:<strong[^>]*>|<b[^>]*>)?\\s*${escapedTitle}\\s*(?:</strong>|</b>)?\\s*</p>\\s*`,
'i'
)
];
let nextContent = content;
for (const pattern of patterns) {
nextContent = nextContent.replace(pattern, '');
}
return nextContent;
}
function normalizeHeadingLevels(content: string): string {
return content
.replace(/<h1(\s|>)/gi, '<h3$1')
.replace(/<\/h1>/gi, '</h3>')
.replace(/<h2(\s|>)/gi, '<h3$1')
.replace(/<\/h2>/gi, '</h3>');
}
export function normalizeAgentLessonContent(content: string, lessonTitle: string): string {
let normalizedContent = content.trim();
normalizedContent = stripLeadingLessonTitle(normalizedContent, lessonTitle);
normalizedContent = normalizeHeadingLevels(normalizedContent);
return normalizedContent.trim();
}
@@ -0,0 +1,108 @@
import { randomUUID } from 'node:crypto';
import { getChatModelContext, upsertChatModelContext } from '@cio/db/queries/agent';
import { trimMessageHistory } from './context-window';
import { summarizeConversationDelta, type SummarizeOptions } from './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
};
}
@@ -0,0 +1,119 @@
import { AppError } from '@cio/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;
});
}
@@ -0,0 +1,138 @@
/**
* 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;
}
@@ -0,0 +1,186 @@
import { generateText } from 'ai';
import { createModel, pickAnyConfiguredProvider } from '@cio/ai-assistant/providers';
import { AppError } from '@cio/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();
}
+227
View File
@@ -0,0 +1,227 @@
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 '@cio/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);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,255 @@
import { AppError, ErrorCodes } from '@cio/utils/errors';
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, 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
@@ -0,0 +1,580 @@
import { AppError, ErrorCodes } from '@cio/utils/errors';
import { sanitizeHtml, sanitizeOptionalHtml, sanitizeUnknownStrings } from '../../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';
@@ -0,0 +1,264 @@
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 '@cio/utils/errors';
import { getDashboardBaseUrl } from '../../config/dashboard-url';
import { updateCourse } from './course';
import { 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 `${getDashboardBaseUrl(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
};
}
@@ -0,0 +1,212 @@
import { AppError, ErrorCodes } from '@cio/utils/errors';
import type { TCourse, TOrganization } from '@cio/db/types';
import type { TCourseLandingPageMetadataUpdate, TCourseLandingPageUpdate } from '@cio/utils/validation/course';
import { env } from '../../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 '../../config/dashboard-url';
import { getOrganizationById } from '@cio/db/queries/organization';
import { updateCourse } from './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 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 `${getDashboardBaseUrl(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
};
}
@@ -0,0 +1,61 @@
import { AppError, ErrorCodes } from '@cio/utils/errors';
import { findNonAutoGradableQuestionsInCourse, getCourseTypeById } from '@cio/db/queries/course';
import { AUTO_GRADABLE_QUESTION_TYPE_IDS, isAutoGradableQuestionTypeId } from '@cio/question-types';
/**
* Throws `QUESTION_TYPE_NOT_ALLOWED_IN_PUBLIC_COURSE` when a non-auto-gradable question
* type is being attached to an exercise that belongs to a `PUBLIC` course.
*
* Accepts the full list of question type ids being created / updated (ignore-null safe).
*/
export async function guardNonAutoGradableQuestionsForCourseType(params: {
courseId: string;
questionTypeIds: Array<number | null | undefined>;
}): Promise<void> {
const typeIds = params.questionTypeIds.filter((id): id is number => typeof id === 'number');
if (typeIds.length === 0) return;
const hasDisallowed = typeIds.some((id) => !isAutoGradableQuestionTypeId(id));
if (!hasDisallowed) return;
const rowType = await getCourseTypeById(params.courseId);
if (!rowType) return;
if (rowType === 'PUBLIC') {
throw new AppError(
'Public courses only support auto-gradable question types',
ErrorCodes.QUESTION_TYPE_NOT_ALLOWED_IN_PUBLIC_COURSE,
400,
'questions'
);
}
}
/**
* When converting a course to `PUBLIC`, enumerate any questions whose type is not
* auto-gradable. Throws `PUBLIC_COURSE_CONVERSION_BLOCKED` with the offending list
* in `message`; callers should surface the list to the creator so they can fix them.
*/
export async function guardCourseTypeTransition(params: {
courseId: string;
currentType: string | null;
nextType: string | null | undefined;
}): Promise<void> {
if (!params.nextType) return;
if (params.nextType !== 'PUBLIC') return;
if (params.currentType === 'PUBLIC') return;
const offenders = await findNonAutoGradableQuestionsInCourse(params.courseId, AUTO_GRADABLE_QUESTION_TYPE_IDS);
if (offenders.length === 0) return;
const preview = offenders.slice(0, 5).map((item) => `"${item.questionTitle}" in "${item.exerciseTitle}"`);
const more = offenders.length > 5 ? ` (+${offenders.length - 5} more)` : '';
throw new AppError(
`Remove or replace these non-auto-gradable questions before converting to Public: ${preview.join('; ')}${more}`,
ErrorCodes.PUBLIC_COURSE_CONVERSION_BLOCKED,
400,
'type'
);
}
@@ -0,0 +1,235 @@
import type { TCourseSection } from '@cio/db/types';
import {
createCourseSections,
deleteCourseSection,
getCourseSectionById,
getCourseSectionsByCourseId,
updateCourseSection,
updateUngroupedLessonsSectionId
} from '@cio/db/queries/course';
import { AppError, ErrorCodes } from '@cio/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
@@ -0,0 +1,42 @@
import { AppError, ErrorCodes } from '@cio/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);
}
+159
View File
@@ -0,0 +1,159 @@
import { ContentType } from '@cio/utils/constants';
import type { CourseContentItemRow } from '@cio/db/queries/course';
export type CourseContentItem = {
id: string;
type: ContentType;
title: string;
order: number | null;
createdAt: string | null;
sectionId: string | null;
isUnlocked: boolean | null;
isComplete: boolean | null;
lessonAt: string | null;
callUrl: string | null;
hasNoteContent: boolean | null;
hasSlideContent: boolean | null;
videosCount: number | null;
documentsCount: number | null;
questionCount: number | null;
dueBy: string | null;
};
export type CourseContentSection = {
id: string;
title: string;
order: number | null;
items: CourseContentItem[];
};
export type CourseContent = {
grouped: boolean;
sections: CourseContentSection[];
items: CourseContentItem[];
};
const CONTENT_TYPE_PRIORITY: Record<ContentType, number> = {
[ContentType.Section]: -1,
[ContentType.Lesson]: 0,
[ContentType.Exercise]: 1
};
function sortContentItems(items: CourseContentItem[]): CourseContentItem[] {
return [...items].sort((a, b) => {
const orderDiff = (a.order ?? 0) - (b.order ?? 0);
if (orderDiff !== 0) return orderDiff;
const typeDiff = CONTENT_TYPE_PRIORITY[a.type] - CONTENT_TYPE_PRIORITY[b.type];
if (typeDiff !== 0) return typeDiff;
return new Date(a.createdAt || 0).getTime() - new Date(b.createdAt || 0).getTime();
});
}
function sortContentSections(sections: CourseContentSection[]): CourseContentSection[] {
return [...sections].sort((a, b) => {
const orderDiff = (a.order ?? 0) - (b.order ?? 0);
if (orderDiff !== 0) return orderDiff;
return a.title.localeCompare(b.title);
});
}
function mapCourseContentItems(rows: CourseContentItemRow[]): CourseContentItem[] {
return rows.map((row) => ({
id: row.id,
type: row.type as ContentType,
title: row.title ?? '',
order: row.order ?? null,
createdAt: row.createdAt ?? null,
sectionId: row.sectionId ?? null,
isUnlocked: row.isUnlocked ?? null,
isComplete: row.isComplete ?? null,
lessonAt: row.lessonAt ?? null,
callUrl: row.callUrl ?? null,
hasNoteContent: row.hasNoteContent ?? null,
hasSlideContent: row.hasSlideContent ?? null,
videosCount: row.videosCount ?? null,
documentsCount: row.documentsCount ?? null,
questionCount: row.questionCount ?? null,
dueBy: row.dueBy ?? null
}));
}
export function buildCourseContent(rows: CourseContentItemRow[], isContentGroupingEnabled: boolean): CourseContent {
const items = mapCourseContentItems(rows);
const contentItems = items.filter((item) => item.type !== ContentType.Section);
if (!isContentGroupingEnabled) {
return {
grouped: false,
sections: [],
items: sortContentItems(contentItems)
};
}
const sectionItems = items.filter((item) => item.type === ContentType.Section);
const sectionMap = new Map<string, CourseContentItem[]>();
const ungroupedItems: CourseContentItem[] = [];
for (const item of contentItems) {
if (!item.sectionId) {
ungroupedItems.push(item);
continue;
}
if (!sectionMap.has(item.sectionId)) {
sectionMap.set(item.sectionId, []);
}
sectionMap.get(item.sectionId)!.push(item);
}
const sections = sortContentSections(
sectionItems.map((section) => ({
id: section.id,
title: section.title || 'Untitled section',
order: section.order ?? null,
items: sortContentItems(sectionMap.get(section.id) ?? [])
}))
);
if (ungroupedItems.length > 0) {
sections.push({
id: 'ungrouped',
title: 'Ungrouped',
order: sections.length + 1,
items: sortContentItems(ungroupedItems)
});
}
return {
grouped: true,
sections,
items: []
};
}
export function calcPercentageWithRounding(a: number, b: number): number {
if (b === 0) return 0;
return Math.round((a / b) * 100);
}
export function formatLastSeen(lastLoginDate: string | null | undefined): string {
if (!lastLoginDate) return 'Never';
const lastLogin = new Date(lastLoginDate);
const now = new Date();
const diffInHours = Math.floor((now.getTime() - lastLogin.getTime()) / (1000 * 60 * 60));
if (diffInHours < 1) {
return 'Just now';
}
if (diffInHours < 24) {
return `${diffInHours} hours ago`;
}
const diffInDays = Math.floor(diffInHours / 24);
return `${diffInDays} days ago`;
}
@@ -0,0 +1,903 @@
import { AppError, ErrorCodes } from '@cio/utils/errors';
import { sanitizeHtml, sanitizeOptionalHtml, sanitizeUnknownStrings } from '../../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 '../course/slug';
import { guardNonAutoGradableQuestionsForCourseType } from '../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
);
}
}
@@ -0,0 +1,455 @@
import { AppError, ErrorCodes } from '@cio/utils/errors';
import type { TExercise, TNewOption, TNewQuestion, TQuestionAnswer, TQuestionType } from '@cio/db/types';
import {
createOptions,
createQuestions,
deleteOptionsByIds,
deleteQuestionsByIds,
getOptionsByQuestionIds,
getQuestionTypesByIds,
getQuestionsByExerciseIds,
updateOptions,
updateQuestions
} from '@cio/db/queries/exercise';
import type { DbOrTxClient } from '@cio/db/drizzle';
import type { TExerciseUpdate } from '@cio/utils/validation/exercise';
type CurrentQuestion = TNewQuestion & { options?: TNewOption[] };
type CurrentOption = TNewOption;
type ExerciseAnswer = Pick<
TQuestionAnswer,
'id' | 'answerData' | 'point' | 'questionId' | 'submissionId' | 'groupMemberId'
> & { id?: number };
export type QuestionWithRelations = {
id: number | string;
exerciseSectionId?: string | null;
value: string;
name: string;
title: string;
type: number;
points: number;
order: number;
questionType: {
id: number;
label: string;
};
questionTypeId: number;
settings?: Record<string, unknown>;
code?: string;
answers?: ExerciseAnswer[];
deletedAt?: string;
createdAt?: string;
updatedAt?: string;
options: {
id: number | string;
label: string | null;
value: string | null;
isCorrect: boolean;
settings?: Record<string, unknown>;
deletedAt?: string;
}[];
};
function normalizeSettings(value: unknown): Record<string, unknown> {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return {};
}
function areSettingsEqual(left: unknown, right: unknown): boolean {
const leftNormalized = normalizeSettings(left);
const rightNormalized = normalizeSettings(right);
return JSON.stringify(leftNormalized) === JSON.stringify(rightNormalized);
}
/**
* Extracts exercise-level fields from the update payload
*/
export function buildExerciseUpdateFields(data: TExerciseUpdate): Partial<TExercise> {
const exerciseUpdate: Partial<TExercise> = {};
if (data.title !== undefined) exerciseUpdate.title = data.title;
if (data.description !== undefined) exerciseUpdate.description = data.description;
if (data.lessonId !== undefined) exerciseUpdate.lessonId = data.lessonId || null;
if (data.sectionId !== undefined) exerciseUpdate.sectionId = data.sectionId || null;
if (data.order !== undefined) exerciseUpdate.order = data.order;
if (data.isUnlocked !== undefined) exerciseUpdate.isUnlocked = data.isUnlocked;
if (data.dueBy !== undefined) exerciseUpdate.dueBy = data.dueBy || null;
if (data.allowMultipleAttempts !== undefined) exerciseUpdate.allowMultipleAttempts = data.allowMultipleAttempts;
if (data.sectionDisplayMode !== undefined) exerciseUpdate.sectionDisplayMode = data.sectionDisplayMode;
if (data.slug !== undefined) exerciseUpdate.slug = data.slug;
return exerciseUpdate;
}
/**
* Categorizes questions into deleted, existing, and new
*/
export function categorizeQuestions(questions: NonNullable<TExerciseUpdate['questions']>) {
const deletedIds: number[] = [];
const existingQuestions: typeof questions = [];
const newQuestions: typeof questions = [];
const questionUpdates: Array<{ id: number; data: Partial<TNewQuestion> }> = [];
for (const q of questions) {
if (q.deletedAt && q.id) {
deletedIds.push(q.id);
} else if (q.id) {
existingQuestions.push(q);
questionUpdates.push({
id: q.id,
data: {
title: q.question,
points: q.points,
questionTypeId: q.questionTypeId,
settings: normalizeSettings(q.settings)
}
});
} else {
newQuestions.push(q);
}
}
return { deletedIds, existingQuestions, newQuestions, questionUpdates };
}
/**
* Categorizes options for a single question into deleted, existing, and new
*/
export function categorizeOptions(
questionId: number,
options: NonNullable<TExerciseUpdate['questions']>[number]['options']
) {
const deletedIds: number[] = [];
const existingUpdates: Array<{ id: number; data: Partial<TNewOption> }> = [];
const newOptions: TNewOption[] = [];
if (!options || options.length === 0) {
return { deletedIds, existingUpdates, newOptions };
}
for (const opt of options) {
if (opt.deletedAt && opt.id) {
deletedIds.push(opt.id);
} else if (opt.id) {
existingUpdates.push({
id: opt.id,
data: {
label: opt.label,
isCorrect: opt.isCorrect,
settings: normalizeSettings(opt.settings)
}
});
} else {
newOptions.push({
questionId,
label: opt.label,
isCorrect: opt.isCorrect,
settings: normalizeSettings(opt.settings)
});
}
}
return { deletedIds, existingUpdates, newOptions };
}
/**
* Compares current question with incoming question data and returns only changed fields
*/
function diffQuestion(
current: CurrentQuestion,
incoming: NonNullable<TExerciseUpdate['questions']>[number]
): Partial<TNewQuestion> | null {
const changes: Partial<TNewQuestion> = {};
let hasChanges = false;
if (incoming.question !== current.title) {
changes.title = incoming.question;
hasChanges = true;
}
if (incoming.points !== undefined && incoming.points !== current.points) {
changes.points = incoming.points;
hasChanges = true;
}
if (incoming.questionTypeId !== undefined && incoming.questionTypeId !== current.questionTypeId) {
changes.questionTypeId = incoming.questionTypeId;
hasChanges = true;
}
if (incoming.order !== undefined && incoming.order !== (current.order ?? undefined)) {
changes.order = incoming.order;
hasChanges = true;
}
if (incoming.exerciseSectionId !== undefined && incoming.exerciseSectionId !== current.exerciseSectionId) {
changes.exerciseSectionId = incoming.exerciseSectionId;
hasChanges = true;
}
if (!areSettingsEqual(current.settings, incoming.settings)) {
changes.settings = normalizeSettings(incoming.settings);
hasChanges = true;
}
return hasChanges ? changes : null;
}
/**
* Compares current option with incoming option data and returns only changed fields
*/
function diffOption(
current: CurrentOption,
incoming: NonNullable<NonNullable<TExerciseUpdate['questions']>[number]['options']>[number]
): Partial<TNewOption> | null {
const changes: Partial<TNewOption> = {};
let hasChanges = false;
if (incoming.label !== current.label) {
changes.label = incoming.label;
hasChanges = true;
}
if (incoming.isCorrect !== current.isCorrect) {
changes.isCorrect = incoming.isCorrect;
hasChanges = true;
}
if (!areSettingsEqual(current.settings, incoming.settings)) {
changes.settings = normalizeSettings(incoming.settings);
hasChanges = true;
}
return hasChanges ? changes : null;
}
/**
* Computes diff between current exercise state and incoming update data
* Returns only the changes that need to be applied
*/
export function computeExerciseDiff(
currentQuestions: CurrentQuestion[],
incomingQuestions: NonNullable<TExerciseUpdate['questions']>
) {
const questionMap = new Map(currentQuestions.map((q) => [q.id, q]));
const deletedQuestionIds: number[] = [];
const questionUpdates: Array<{ id: number; data: Partial<TNewQuestion> }> = [];
const newQuestions: NonNullable<TExerciseUpdate['questions']> = [];
// Process incoming questions
for (const incoming of incomingQuestions) {
if (incoming.deletedAt && incoming.id) {
deletedQuestionIds.push(incoming.id);
} else if (incoming.id) {
const current = questionMap.get(incoming.id);
if (current) {
const changes = diffQuestion(current, incoming);
if (changes) {
questionUpdates.push({ id: incoming.id, data: changes });
}
} else {
// Question ID exists but not in current state - treat as new
newQuestions.push(incoming);
}
} else {
newQuestions.push(incoming);
}
}
// Compute option diffs for existing questions
const optionDeletes: number[] = [];
const optionUpdates: Array<{ id: number; data: Partial<TNewOption> }> = [];
const optionCreates: TNewOption[] = [];
for (const incoming of incomingQuestions) {
if (!incoming.id || incoming.deletedAt) continue;
const current = questionMap.get(incoming.id);
if (!current) continue;
const currentOptionMap = new Map((current.options || []).map((opt) => [opt.id, opt]));
const incomingOptions = incoming.options || [];
const processedIncomingOptionIds = new Set<number>();
for (const incomingOpt of incomingOptions) {
if (incomingOpt.deletedAt && incomingOpt.id) {
optionDeletes.push(incomingOpt.id);
processedIncomingOptionIds.add(incomingOpt.id);
} else if (incomingOpt.id) {
processedIncomingOptionIds.add(incomingOpt.id);
const currentOpt = currentOptionMap.get(incomingOpt.id);
if (currentOpt) {
const changes = diffOption(currentOpt, incomingOpt);
if (changes) {
optionUpdates.push({ id: incomingOpt.id, data: changes });
}
} else {
// Option ID exists in incoming but not in current - treat as create
optionCreates.push({
questionId: incoming.id,
label: incomingOpt.label,
isCorrect: incomingOpt.isCorrect,
settings: normalizeSettings(incomingOpt.settings),
value: crypto.randomUUID()
});
}
} else {
// New option without ID
optionCreates.push({
questionId: incoming.id,
label: incomingOpt.label,
isCorrect: incomingOpt.isCorrect,
settings: normalizeSettings(incomingOpt.settings),
value: crypto.randomUUID()
});
}
}
// Check for options that were deleted (exist in current but not in incoming)
for (const [optId] of currentOptionMap) {
if (optId !== undefined && !processedIncomingOptionIds.has(optId)) {
optionDeletes.push(optId);
}
}
}
return {
questions: {
deletedIds: deletedQuestionIds,
updates: questionUpdates,
newQuestions
},
options: {
deletedIds: optionDeletes,
updates: optionUpdates,
creates: optionCreates
}
};
}
/**
* Creates new questions and their options in batch
*/
export async function createNewQuestionsWithOptions(
exerciseId: string,
newQuestions: NonNullable<TExerciseUpdate['questions']>,
txClient: DbOrTxClient
): Promise<void> {
const createdQuestions = await createQuestions(
newQuestions.map((q) => ({
exerciseId,
title: q.question,
questionTypeId: q.questionTypeId || 1,
points: q.points || 0,
order: typeof q.order === 'number' ? q.order : undefined,
exerciseSectionId: q.exerciseSectionId ?? null,
settings: normalizeSettings(q.settings)
})),
txClient
);
const newOptions: TNewOption[] = [];
createdQuestions.forEach((newQuestion, index) => {
const questionData = newQuestions[index];
if (newQuestion && questionData?.options && questionData.options.length > 0) {
// Only include non-deleted options for new questions
for (const opt of questionData.options) {
if (!opt.deletedAt) {
newOptions.push({
questionId: newQuestion.id,
label: opt.label,
isCorrect: opt.isCorrect,
settings: normalizeSettings(opt.settings)
});
}
}
}
});
if (newOptions.length > 0) {
await createOptions(newOptions, txClient);
}
}
/**
* Fetches questions and their options for an exercise
*/
export async function fetchQuestionsAndOptions(exerciseId: string, dbClient?: DbOrTxClient) {
const questions = await getQuestionsByExerciseIds([exerciseId], dbClient);
const questionIds = questions.map((q) => q.id).filter((id): id is number => id !== undefined);
const options = questionIds.length > 0 ? await getOptionsByQuestionIds(questionIds, dbClient) : [];
return { questions, options };
}
/**
* Fetches question types and creates a lookup map
*/
export async function fetchQuestionTypesMap(
questions: TNewQuestion[],
dbClient?: DbOrTxClient
): Promise<Map<number, TQuestionType>> {
const questionTypeIds = [
...new Set(questions.map((q) => q.questionTypeId).filter((id): id is number => id !== undefined))
];
const questionTypes: TQuestionType[] =
questionTypeIds.length > 0 ? await getQuestionTypesByIds(questionTypeIds, dbClient) : [];
return new Map(questionTypes.map((qt) => [qt.id, qt]));
}
/**
* Transforms a single question with its options to frontend format
*/
export function transformQuestion(
question: TNewQuestion,
options: TNewOption[],
questionTypeMap: Map<number, TQuestionType>
): QuestionWithRelations {
const questionType = questionTypeMap.get(question.questionTypeId);
const questionTypeId = question.questionTypeId;
const questionOptions = options
.filter((opt) => opt.questionId === question.id)
.map((opt) => ({
id: opt.id!,
label: opt.label ?? null,
value: opt.value ?? null,
isCorrect: opt.isCorrect ?? false,
settings: normalizeSettings(opt.settings)
}));
return {
id: question.id!,
exerciseSectionId: question.exerciseSectionId ?? null,
value: question.title || '', // value field defaults to title
name: question.name || '',
title: question.title,
type: questionTypeId, // type field (same as questionTypeId)
points: question.points || 0,
order: question.order || 0,
questionType: questionType
? {
id: questionType.id,
label: questionType.label
}
: {
id: questionTypeId,
label: ''
},
questionTypeId: questionTypeId,
settings: normalizeSettings(question.settings),
createdAt: question.createdAt || undefined,
updatedAt: question.updatedAt || undefined,
options: questionOptions
};
}
/**
* Transforms all questions to frontend format
*/
export function transformQuestions(
questions: TNewQuestion[],
options: TNewOption[],
questionTypeMap: Map<number, TQuestionType>
): QuestionWithRelations[] {
return questions.map((question) => transformQuestion(question, options, questionTypeMap));
}
@@ -0,0 +1,337 @@
import { AppError, ErrorCodes } from '@cio/utils/errors';
import { JOB_STATUS, type JobEnvelope, type JobStatus, suggestNextPollMs } from '@cio/jobs/status';
import {
createMediaJob,
getMediaJobById,
listJobStepsByRun,
listLatestMediaJobsByAsset,
listMediaJobsByIds,
requestMediaJobCancel,
updateMediaJob,
hasActiveMediaJobForAsset
} from '@cio/db/queries';
import { getAssetById } from '@cio/db/queries/assets';
import {
enqueueGenerateThumbnailOnly,
enqueueLessonVideoPipeline,
enqueueTranscriptionOnly,
isRedisConfigured
} from '@cio/jobs';
import { logRedisUnavailableOnce } from '../../utils/redis/redis';
import type { TMediaJob } from '@cio/db/types';
export interface StartTranscriptionOnlyMediaJobInput {
organizationId: string;
assetId: string;
triggeredByProfileId: string | null;
}
/**
* Manual transcription: new `media_job` + extract-audio → transcribe-audio only.
* Requires OpenAI key, upload video asset, and no other active media_job for the asset.
*/
export async function startTranscriptionOnlyMediaJob(input: StartTranscriptionOnlyMediaJobInput): Promise<TMediaJob> {
if (!process.env.OPENAI_API_KEY) {
throw new AppError('Transcription not configured', ErrorCodes.OPENAI_KEY_MISSING, 503);
}
const asset = await getAssetById(input.assetId);
if (!asset || asset.organizationId !== input.organizationId) {
throw new AppError('Asset not found', ErrorCodes.NOT_FOUND, 404);
}
// HLS uploads carry their pre-extracted audio in `hls_audio_key` and leave
// `storage_key` null so lesson rendering doesn't sign the audio file as
// the canonical video URL. Raw MP4 uploads continue to use `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);
}
const hasActive = await hasActiveMediaJobForAsset(input.assetId, input.organizationId);
if (hasActive) {
throw new AppError('A media job is already running for this asset', ErrorCodes.CONFLICT, 409);
}
const job = await createMediaJob({
organizationId: input.organizationId,
assetId: input.assetId,
triggeredByProfileId: input.triggeredByProfileId,
status: 'queued',
stage: 'transcribing',
progressPercent: 0
});
if (!isRedisConfigured()) {
logRedisUnavailableOnce(
'Redis not configured: transcription media job created but no BullMQ flow enqueued. ' +
'Set REDIS_URL and run apps/jobs to process this job.'
);
return job;
}
try {
const enqueueResult = await enqueueTranscriptionOnly({
mediaJobId: job.id,
assetId: input.assetId,
storageKey: transcriptionSourceKey,
actorContext: {
userId: input.triggeredByProfileId,
organizationId: input.organizationId
}
});
const updated = await updateMediaJob(job.id, {
rootJobId: enqueueResult.rootJobId,
jobIds: enqueueResult.jobIds
});
return updated ?? job;
} catch (error) {
console.error('startTranscriptionOnlyMediaJob enqueue failed:', error);
await updateMediaJob(job.id, {
status: 'failed',
stage: 'failed',
error: {
code: 'ENQUEUE_FAILED',
message: error instanceof Error ? error.message : 'Failed to enqueue transcription job'
}
});
throw new AppError(
error instanceof Error ? error : 'Failed to enqueue transcription job',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export interface StartThumbnailRegenInput {
organizationId: string;
assetId: string;
triggeredByProfileId: string | null;
}
/**
* Manual thumbnail regeneration: create a new `media_job` row and enqueue only
* the `generate-thumbnail` step. Rejects when the asset has no usable storage
* key or when another media job is already running for it.
*/
export async function startThumbnailRegenJob(input: StartThumbnailRegenInput): Promise<TMediaJob> {
const asset = await getAssetById(input.assetId);
if (!asset || asset.organizationId !== input.organizationId) {
throw new AppError('Asset not found', ErrorCodes.NOT_FOUND, 404);
}
if (asset.kind !== 'video' || asset.provider !== 'upload' || !asset.storageKey) {
throw new AppError('Asset cannot be thumbnailed', ErrorCodes.ASSET_NOT_TRANSCRIBABLE, 400);
}
const hasActive = await hasActiveMediaJobForAsset(input.assetId, input.organizationId);
if (hasActive) {
throw new AppError('A media job is already running for this asset', ErrorCodes.CONFLICT, 409);
}
const job = await createMediaJob({
organizationId: input.organizationId,
assetId: input.assetId,
triggeredByProfileId: input.triggeredByProfileId,
status: 'queued',
stage: 'thumbnailing',
progressPercent: 0
});
if (!isRedisConfigured()) {
logRedisUnavailableOnce(
'Redis not configured: thumbnail regen media job created but no BullMQ flow enqueued. ' +
'Set REDIS_URL and run apps/jobs to process this job.'
);
return job;
}
try {
const enqueueResult = await enqueueGenerateThumbnailOnly({
mediaJobId: job.id,
assetId: input.assetId,
storageKey: asset.storageKey,
actorContext: {
userId: input.triggeredByProfileId,
organizationId: input.organizationId
}
});
const updated = await updateMediaJob(job.id, {
rootJobId: enqueueResult.rootJobId,
jobIds: enqueueResult.jobIds
});
return updated ?? job;
} catch (error) {
console.error('startThumbnailRegenJob enqueue failed:', error);
await updateMediaJob(job.id, {
status: 'failed',
stage: 'failed',
error: {
code: 'ENQUEUE_FAILED',
message: error instanceof Error ? error.message : 'Failed to enqueue thumbnail job'
}
});
throw new AppError(
error instanceof Error ? error : 'Failed to enqueue thumbnail job',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export interface StartMediaJobInput {
organizationId: string;
assetId: string;
storageKey: string;
triggeredByProfileId: string | null;
/** Set to false for assets without an OpenAI key (skip transcribe leg). */
withTranscription?: boolean;
}
/**
* Create a `media_job` row and enqueue the BullMQ flow. Falls back to a
* persisted `queued` row with no BullMQ job id when Redis is unavailable so
* the API returns a stable status envelope and a follow-up `start` worker
* can pick it up later. The enqueue itself fails loudly (the DB row stays
* around so an operator can replay it).
*/
export async function startMediaJob(input: StartMediaJobInput): Promise<TMediaJob> {
const job = await createMediaJob({
organizationId: input.organizationId,
assetId: input.assetId,
triggeredByProfileId: input.triggeredByProfileId,
status: 'queued',
stage: 'queued',
progressPercent: 0
});
if (!isRedisConfigured()) {
logRedisUnavailableOnce(
'Redis not configured: media job created but no BullMQ flow enqueued. ' +
'Set REDIS_URL and run apps/jobs to process this job.'
);
return job;
}
try {
const enqueueResult = await enqueueLessonVideoPipeline({
mediaJobId: job.id,
assetId: input.assetId,
storageKey: input.storageKey,
actorContext: {
userId: input.triggeredByProfileId,
organizationId: input.organizationId
},
withTranscription: input.withTranscription
});
const updated = await updateMediaJob(job.id, {
rootJobId: enqueueResult.rootJobId,
jobIds: enqueueResult.jobIds
});
return updated ?? job;
} catch (error) {
console.error('startMediaJob enqueue failed:', error);
await updateMediaJob(job.id, {
status: 'failed',
stage: 'failed',
error: {
code: 'ENQUEUE_FAILED',
message: error instanceof Error ? error.message : 'Failed to enqueue media job'
}
});
throw new AppError(error instanceof Error ? error : 'Failed to enqueue media job', ErrorCodes.INTERNAL_ERROR, 500);
}
}
export interface GetMediaJobEnvelopeOptions {
pollCount?: number;
}
function toEnvelope(job: TMediaJob, options: GetMediaJobEnvelopeOptions = {}): JobEnvelope {
const status = job.status as JobStatus;
const pollCount = options.pollCount ?? 0;
return {
job: {
id: job.id,
domain: 'media',
status,
stage: job.stage,
progressPercent: job.progressPercent,
createdAt: job.createdAt,
updatedAt: job.updatedAt,
result: job.result ?? null,
error: job.error ?? null,
warnings: job.warnings ?? [],
cancelRequestedAt: job.cancelRequestedAt,
attempt: job.attempt,
maxAttempts: job.maxAttempts
},
events: [],
nextPollMs: suggestNextPollMs(status, pollCount)
};
}
export async function getMediaJobEnvelope(
jobId: string,
orgId: string,
options: GetMediaJobEnvelopeOptions = {}
): Promise<JobEnvelope> {
const job = await getMediaJobById(jobId, orgId);
if (!job) {
throw new AppError('Media job not found', ErrorCodes.NOT_FOUND, 404);
}
return toEnvelope(job, options);
}
export async function listMediaJobEnvelopes(jobIds: string[], orgId: string): Promise<Record<string, JobEnvelope>> {
if (jobIds.length === 0) return {};
const rows = await listMediaJobsByIds(jobIds, orgId);
const map: Record<string, JobEnvelope> = {};
for (const row of rows) {
map[row.id] = toEnvelope(row);
}
return map;
}
export async function listMediaJobsForAssetEnvelopes(assetId: string, orgId: string): Promise<JobEnvelope[]> {
const rows = await listLatestMediaJobsByAsset(assetId, orgId);
return rows.map((row) => toEnvelope(row));
}
export async function cancelMediaJob(jobId: string, orgId: string): Promise<JobEnvelope> {
const job = await getMediaJobById(jobId, orgId);
if (!job) {
throw new AppError('Media job not found', ErrorCodes.NOT_FOUND, 404);
}
if (job.status === JOB_STATUS.completed || job.status === JOB_STATUS.failed || job.status === JOB_STATUS.canceled) {
return toEnvelope(job);
}
const updated = await requestMediaJobCancel(jobId, orgId);
return toEnvelope(updated ?? job);
}
export async function getMediaJobSteps(jobId: string, orgId: string) {
const job = await getMediaJobById(jobId, orgId);
if (!job) {
throw new AppError('Media job not found', ErrorCodes.NOT_FOUND, 404);
}
return listJobStepsByRun('media', jobId);
}
@@ -0,0 +1,171 @@
import { AppError, ErrorCodes } from '@cio/utils/errors';
import { sanitizeOptionalHtml } from '../utils/sanitize-html';
import type { TLessonLanguage, TLocale, TNewLessonLanguage } from '@cio/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
);
}
}
+408
View File
@@ -0,0 +1,408 @@
import { AppError, ErrorCodes } from '@cio/utils/errors';
import { sanitizeHtml } from '../../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 '../../utils/lesson-media';
import { resolveItemSlug } from '../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
);
}
}
+158
View File
@@ -0,0 +1,158 @@
import { enrichObjectsWithUrls, extractKeysFromObjects } from './presigned-urls';
import { generateDocumentDownloadPresignedUrls, generateVideoDownloadPresignedUrls } from './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 assetMetadata =
typeof asset.metadata === 'object' && asset.metadata !== null && !Array.isArray(asset.metadata)
? (asset.metadata as Record<string, unknown>)
: {};
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,
...(assetMetadata.format === 'hls' ? { hls: true } : {}),
...(typeof assetMetadata.sourceWidth === 'number' ? { sourceWidth: assetMetadata.sourceWidth } : {}),
...(typeof assetMetadata.sourceHeight === 'number' ? { sourceHeight: assetMetadata.sourceHeight } : {}),
...(Array.isArray(assetMetadata.hlsRenditions) ? { hlsRenditions: assetMetadata.hlsRenditions } : {}),
...(typeof assetMetadata.hls1080Status === 'string' ? { hls1080Status: assetMetadata.hls1080Status } : {})
};
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)
};
}
+77
View File
@@ -0,0 +1,77 @@
/**
* Generic utilities for working with presigned URLs
* These functions are reusable across multiple services that need to enrich
* objects with presigned URLs based on keys
*/
/**
* Extracts keys from an array of objects that have a `key` property
* @param items Array of objects with a key property
* @returns Array of string keys
*/
export function extractKeysFromObjects<T extends { key?: string }>(items: T[]): string[] {
return items.reduce<string[]>((keys, item) => {
if (item.key) {
keys.push(item.key);
}
return keys;
}, []);
}
/**
* Enriches an array of objects with URLs from a key-to-URL mapping
* Adds a `link` property to objects that have a matching key in the urls record
* @param items Array of objects to enrich
* @param urls Record mapping keys to presigned URLs
* @returns New array with enriched objects (immutable)
*/
export function enrichObjectsWithUrls<T extends { key?: string }>(
items: T[],
urls: Record<string, string>
): Array<T & { link?: string }> {
return items.map((item) => {
if (item.key && urls[item.key]) {
return { ...item, link: urls[item.key] };
}
return item;
});
}
/**
* Generic function to extract keys from objects with a custom key extractor
* @param items Array of items
* @param keyExtractor Function to extract the key from an item
* @returns Array of string keys
*/
export function extractKeysFromArray<T>(items: T[], keyExtractor: (item: T) => string | undefined): string[] {
return items.reduce<string[]>((keys, item) => {
const key = keyExtractor(item);
if (key) {
keys.push(key);
}
return keys;
}, []);
}
/**
* Generic function to enrich array items with URLs
* @param items Array of items to enrich
* @param urls Record mapping keys to presigned URLs
* @param keyGetter Function to get the key from an item
* @param urlSetter Function to set the URL on an item
* @returns New array with enriched items (immutable)
*/
export function enrichArrayWithUrls<T>(
items: T[],
urls: Record<string, string>,
keyGetter: (item: T) => string | undefined,
urlSetter: (item: T, url: string) => T
): T[] {
return items.map((item) => {
const key = keyGetter(item);
if (key && urls[key]) {
return urlSetter(item, urls[key]);
}
return item;
});
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Pure (no HTTP context) redis key helpers used by the agent runtime.
*
* The full key-generator set lives in `apps/api/src/utils/redis/key-generators.ts`
* because most of them take a Hono `Context` to derive client identity. The
* agent runtime only needs the context-free subset, kept here so this package
* stays free of hono deps.
*/
/**
* Redis key for storing extracted document text from AI assistant uploads.
* TTL: 3600 seconds (1 hour).
*/
export const agentDocumentKey = (documentId: string): string => {
return `agent:document:${documentId}`;
};
+57
View File
@@ -0,0 +1,57 @@
import { createClient, type RedisClientType } from 'redis';
import { env } from '../../config/env';
// Create Redis client
const client: RedisClientType = createClient({
url: env.REDIS_URL
});
let hasLoggedRedisUnavailable = false;
export function logRedisUnavailableOnce(message: string, error?: unknown): void {
if (hasLoggedRedisUnavailable) {
return;
}
hasLoggedRedisUnavailable = true;
if (error) {
console.warn(message, error);
return;
}
console.warn(message);
}
// Handle connection events
client.on('error', (err) => {
logRedisUnavailableOnce('Redis unavailable, Redis-backed features disabled', err);
});
client.on('connect', () => {
console.log('Redis Client Connected');
});
// Track connection state
let isConnected = false;
export async function connectRedis(): Promise<void> {
if (!env.REDIS_URL) {
logRedisUnavailableOnce('REDIS_URL not set, Redis features disabled');
return;
}
if (isConnected) return;
try {
await client.connect();
isConnected = true;
} catch (error) {
logRedisUnavailableOnce('Redis connection failed, API will run without Redis', error);
// Don't throw - allow API to start; rate limiting and caching will degrade gracefully
}
}
// Export the client (connection will be established before server starts)
export const redis: RedisClientType = client;
// Export type for use in other files
export type RedisClient = RedisClientType;
+215
View File
@@ -0,0 +1,215 @@
import {
DeleteObjectCommand,
DeleteObjectCommandInput,
GetObjectCommand,
GetObjectCommandInput,
PutObjectCommand,
PutObjectCommandInput
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { getPresignS3Client, getS3Client, getStorageConfig } from '../config/storage';
import { logRedisUnavailableOnce, redis } from './redis/redis';
export type GetSignedUrlParameters = Parameters<typeof getSignedUrl>;
const PRESIGN_CACHE_TTL_SECONDS = 3000; // 50 min; presigned URLs typically valid 1 hour
const PRESIGN_CACHE_KEY_PREFIX = 'presign:download:';
export async function uploadToS3(params: PutObjectCommandInput): Promise<{ success: boolean; error?: string }> {
try {
await getS3Client().send(new PutObjectCommand(params));
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
}
export async function getFromS3(
params: GetObjectCommandInput
): Promise<{ success: boolean; data?: any; error?: string }> {
try {
const data = await getS3Client().send(new GetObjectCommand(params));
return { success: true, data };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
}
export async function deleteFromS3(params: DeleteObjectCommandInput): Promise<{ success: boolean; error?: string }> {
try {
await getS3Client().send(new DeleteObjectCommand(params));
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
}
/**
* Generates presigned URLs for download for a list of keys in a specific bucket.
* Uses Redis cache to avoid repeated S3 getSignedUrl calls for the same file.
* @param keys Array of S3 keys
* @param bucketName Bucket name (e.g., 'videos', 'documents')
* @returns Record mapping keys to presigned URLs
*/
export async function generateDownloadPresignedUrls(
keys: string[],
bucketName: string
): Promise<Record<string, string>> {
const signedUrls: Record<string, string> = {};
const config = getStorageConfig();
const client = getPresignS3Client();
if (keys.length === 0) {
return signedUrls;
}
const cacheKeys = keys.map((key) => `${PRESIGN_CACHE_KEY_PREFIX}${bucketName}:${key}`);
let cachedValues: (string | null)[] | null = null;
try {
cachedValues = await redis.mGet(cacheKeys);
} catch (error) {
logRedisUnavailableOnce('Redis cache unavailable for presigned URLs, falling back to S3', error);
}
const keysToGenerate: string[] = [];
if (cachedValues) {
cachedValues.forEach((val, i) => {
if (val) {
signedUrls[keys[i]] = val;
} else {
keysToGenerate.push(keys[i]);
}
});
} else {
keysToGenerate.push(...keys);
}
if (keysToGenerate.length === 0) {
return signedUrls;
}
const urlPromises = keysToGenerate.map(async (key) => {
try {
const command = new GetObjectCommand({
Bucket: bucketName,
Key: key
}) as GetSignedUrlParameters[1];
const presignedUrl = await getSignedUrl(client as GetSignedUrlParameters[0], command, {
expiresIn: config.presignDownloadExpiresSeconds
});
return { key, presignedUrl };
} catch (error) {
console.error(`Failed to generate presigned URL for key "${key}" in bucket "${bucketName}":`, error);
return null;
}
});
const results = await Promise.all(urlPromises);
try {
const pipeline = redis.multi();
results.forEach((result) => {
if (result) {
signedUrls[result.key] = result.presignedUrl;
const cacheKey = `${PRESIGN_CACHE_KEY_PREFIX}${bucketName}:${result.key}`;
pipeline.setEx(cacheKey, PRESIGN_CACHE_TTL_SECONDS, result.presignedUrl);
}
});
await pipeline.exec();
} catch (error) {
logRedisUnavailableOnce('Redis cache write unavailable for presigned URLs, continuing without cache', error);
// URLs are already in signedUrls; cache write failure is non-fatal
}
return signedUrls;
}
/**
* Generates a presigned URL for upload
* @param fileKey S3 key for the file
* @param bucketName Bucket name (e.g., 'videos', 'documents')
* @param contentType MIME type of the file
* @returns Presigned URL for upload
*/
export async function generateUploadPresignedUrl(
fileKey: string,
bucketName: string,
contentType: string
): Promise<string> {
const config = getStorageConfig();
const client = getPresignS3Client();
const command = new PutObjectCommand({
Bucket: bucketName,
Key: fileKey,
ContentType: contentType
}) as GetSignedUrlParameters[1];
const presignedUrl = await getSignedUrl(client as GetSignedUrlParameters[0], command, {
expiresIn: config.presignUploadExpiresSeconds
});
return presignedUrl;
}
/** 6h presigned GET for private transcript VTT objects (not Redis-cached). */
export const TRANSCRIPT_VTT_PRESIGN_SECONDS = 21_600;
export async function generateTranscriptVttPresignedUrl(key: string, bucketName: string): Promise<string> {
const client = getPresignS3Client();
const command = new GetObjectCommand({
Bucket: bucketName,
Key: key
}) as GetSignedUrlParameters[1];
return getSignedUrl(client as GetSignedUrlParameters[0], command, {
expiresIn: TRANSCRIPT_VTT_PRESIGN_SECONDS
});
}
/**
* Generates presigned URLs for video download
* @param keys Array of video keys
* @returns Record mapping keys to presigned URLs
*/
export async function generateVideoDownloadPresignedUrls(keys: string[]): Promise<Record<string, string>> {
const config = getStorageConfig();
return generateDownloadPresignedUrls(keys, config.bucketVideos);
}
/**
* Generates presigned URLs for document download
* @param keys Array of document keys
* @returns Record mapping keys to presigned URLs
*/
export async function generateDocumentDownloadPresignedUrls(keys: string[]): Promise<Record<string, string>> {
const config = getStorageConfig();
return generateDownloadPresignedUrls(keys, config.bucketDocuments);
}
/**
* Generates a presigned URL for video upload
* @param fileKey S3 key for the video file
* @param contentType MIME type of the video file
* @returns Presigned URL for upload
*/
export async function generateVideoUploadPresignedUrl(fileKey: string, contentType: string): Promise<string> {
const config = getStorageConfig();
return generateUploadPresignedUrl(fileKey, config.bucketVideos, contentType);
}
/**
* Generates a presigned URL for document upload
* @param fileKey S3 key for the document file
* @param contentType MIME type of the document file
* @returns Presigned URL for upload
*/
export async function generateDocumentUploadPresignedUrl(fileKey: string, contentType: string): Promise<string> {
const config = getStorageConfig();
return generateUploadPresignedUrl(fileKey, config.bucketDocuments, contentType);
}
+39
View File
@@ -0,0 +1,39 @@
import { createSanitizeHtmlConfig, stripSvgDataUrls } from '@cio/utils/functions';
import DOMPurify from 'isomorphic-dompurify';
export function sanitizeHtml(html: string): string {
let sanitized = DOMPurify.sanitize(html, createSanitizeHtmlConfig());
sanitized = stripSvgDataUrls(sanitized);
return sanitized;
}
export function sanitizeOptionalHtml(html: string | undefined): string | undefined;
export function sanitizeOptionalHtml(html: string | null | undefined): string | null | undefined;
export function sanitizeOptionalHtml(html: string | null | undefined): string | null | undefined {
if (html == null) return html;
return sanitizeHtml(html);
}
export function sanitizeUnknownStrings<T>(value: T): T {
if (typeof value === 'string') {
return sanitizeHtml(value) as T;
}
if (Array.isArray(value)) {
return value.map((item) => sanitizeUnknownStrings(item)) as T;
}
if (value && typeof value === 'object') {
const sanitizedEntries = Object.entries(value).map(([key, entryValue]) => [
key,
sanitizeUnknownStrings(entryValue)
]);
return Object.fromEntries(sanitizedEntries) as T;
}
return value;
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Tinybird event tracking for AI assistant observability.
* Fire-and-forget: never blocks the agent, silently skipped when not configured.
*/
const TINYBIRD_TOKEN = process.env.TINYBIRD_TOKEN;
const TINYBIRD_BASE_URL = process.env.TINYBIRD_BASE_URL || 'https://api.tinybird.co';
/**
* Send an event to Tinybird for analytics and debugging.
* Safe to call without awaiting — errors are swallowed.
*/
export function trackAgentEvent(eventName: string, data: Record<string, unknown>): void {
if (!TINYBIRD_TOKEN) return;
fetch(`${TINYBIRD_BASE_URL}/v0/events?name=${eventName}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${TINYBIRD_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ ...data, timestamp: new Date().toISOString() })
}).catch(() => {
// Intentionally swallowed — observability must never break the agent
});
}
// ─── Event Names ─────────────────────────────────────────────────────────────
export const AgentEvent = {
CHAT_STARTED: 'agent.chat.started',
CHAT_COMPLETED: 'agent.chat.completed',
CHAT_ERROR: 'agent.chat.error',
TOOL_CALLED: 'agent.tool.called',
TOOL_COMPLETED: 'agent.tool.completed',
TOOL_FAILED: 'agent.tool.failed',
DOCUMENT_UPLOADED: 'agent.document.uploaded',
PLAN_GENERATED: 'agent.plan.generated',
PLAN_EXECUTED: 'agent.plan.executed'
} as const;
+34
View File
@@ -0,0 +1,34 @@
import { nanoid } from 'nanoid';
import path from 'path';
/**
* Safely extracts the file extension from a filename.
* Returns an empty string if no extension is found.
*/
export function getExtension(filename: string): string {
if (!filename || typeof filename !== 'string') return '';
const ext = path.extname(filename).split('.');
return ext.length > 1 ? ext[ext.length - 1] : '';
}
export function removeSpacesAndSpecialCharacters(fileName: string): string {
return fileName
.replace(/\s+/g, '-')
.replace(/[^a-zA-Z0-9.-]/g, '')
.toLowerCase();
}
/**
* Generates a unique file key for storage, preserving the file extension.
* Throws an error if the filename is invalid or has no extension.
*/
export function generateFileKey(fileName: string): string {
const ext = getExtension(fileName);
if (!ext) {
throw new Error('Invalid file name or missing file extension');
}
const cleanedFileName = removeSpacesAndSpecialCharacters(fileName);
return `${nanoid()}-${cleanedFileName}`;
}
+34
View File
@@ -0,0 +1,34 @@
{
"extends": "@cio/tsconfig/base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": ".",
"resolveJsonModule": true,
"module": "ESNext",
"target": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"declaration": true,
"declarationMap": true,
"lib": ["ES2022", "DOM"],
"typeRoots": ["../../node_modules/@types", "./node_modules/@types"],
"types": ["node"],
"paths": {
"@cio/db/types": ["./node_modules/@cio/db/dist/types.d.ts"],
"@cio/db/*": ["./node_modules/@cio/db/dist/*"],
"@cio/utils": ["./node_modules/@cio/utils/dist/index.d.ts"],
"@cio/utils/*": ["./node_modules/@cio/utils/dist/*"],
"@cio/jobs": ["./node_modules/@cio/jobs/dist/index.d.ts"],
"@cio/jobs/*": ["./node_modules/@cio/jobs/dist/*"]
}
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "tsconfig.tsbuildinfo"],
"references": [{ "path": "../db" }, { "path": "../utils" }, { "path": "../ai-assistant" }, { "path": "../jobs" }],
"tsc-alias": {
"resolveFullPaths": true,
"verbose": false
}
}