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
+42 -18
View File
@@ -13,9 +13,7 @@ export type BuildTeacherSystemPromptOptions = {
};
function buildQuestionTypeListBlock(isOrgOnPaidPlan: boolean): string {
const allowed = QUESTION_TYPE_REGISTRY.filter(
(t) => isOrgOnPaidPlan || !PREMIUM_QUESTION_TYPE_KEYS.has(t.key)
);
const allowed = QUESTION_TYPE_REGISTRY.filter((t) => isOrgOnPaidPlan || !PREMIUM_QUESTION_TYPE_KEYS.has(t.key));
const listing = allowed.map((t) => `- ${t.id} = ${t.typename} — ${t.label}`).join('\n');
if (isOrgOnPaidPlan) {
@@ -31,10 +29,7 @@ function buildQuestionTypeListBlock(isOrgOnPaidPlan: boolean): string {
The following question types require a paid plan and are NOT available on this org: ${blocked}. Do NOT attempt to create them — pick one of the types listed above instead. If the teacher asks for one of these, briefly explain that it requires an upgrade and suggest the closest available type (e.g. RADIO instead of STAR for a rating-style question).`;
}
export function buildTeacherSystemPrompt(
context: AgentContext,
options?: BuildTeacherSystemPromptOptions
): string {
export function buildTeacherSystemPrompt(context: AgentContext, options?: BuildTeacherSystemPromptOptions): string {
const isOrgOnPaidPlan = options?.isOrgOnPaidPlan ?? true;
const questionTypeListBlock = buildQuestionTypeListBlock(isOrgOnPaidPlan);
@@ -50,7 +45,29 @@ These are the only question type IDs supported by this platform. Always use thes
${questionTypeListBlock}
Every question you pass to \`create_exercise\` or \`add_questions\` MUST include an explicit \`questionTypeId\` matching one of the IDs above (tool validation rejects missing values). For exercises with **6 or more** questions, use **at least three different** \`questionTypeId\` values across the exercise (for example mix RADIO, CHECKBOX, TRUE_FALSE, NUMERIC, or WORD_BANK where appropriate) — do not create long runs of only RADIO (single answer).
Every question you pass to \`create_exercise\` or \`add_questions\` MUST include an explicit \`questionTypeId\` matching one of the IDs above (tool validation rejects missing values).
**Type variety is required, not optional.** Defaulting every question to RADIO or CHECKBOX is a quality bug — the platform supports many question types because different concepts need different assessment shapes. Use the following as the floor, not the ceiling:
- **3–5 questions:** at least 2 distinct \`questionTypeId\` values.
- **6+ questions:** at least 4 distinct \`questionTypeId\` values. No more than half the questions in a single exercise may share one type.
- **A whole exam (final-exam section, multiple blocks):** every available non-legacy type listed above should appear at least once across the exam if it fits a question naturally.
**Pick the type that matches the cognitive skill being tested, not the type that's easiest to write:**
- **TRUE_FALSE** — testing a single misconception. Cheap and high-signal when the statement targets a *common* wrong belief, not a trivia detail.
- **SHORT_ANSWER** — recalling a specific term, command, value, or short phrase. Use when there's a small set of right answers you can list.
- **NUMERIC** — anything quantitative: math, calculations, counts, sizes, rates. Default for any "how many / what value / calculate X" question.
- **FILL_BLANK** — testing syntax, sequence completion, or code/sentence patterns where position matters ("\`SELECT \_\_ FROM users\`").
- **MATCHING** — pairing related concepts (term ↔ definition, problem ↔ solution, tool ↔ purpose). Use whenever you'd otherwise write multiple RADIO questions all asking "what is the definition of X?".
- **ORDERING** — sequencing steps, ranking by criterion, timeline reconstruction. Use for any "what's the right order" question instead of RADIO with shuffled options.
- **WORD_BANK** — vocab application, classification, filling multiple blanks from a shared pool. Stronger than RADIO when the same concept set applies to many slots.
- **TEXTAREA** — open-ended explanation, reflection, written analysis. Use sparingly (manual grading).
- **HOTSPOT** — identifying a region of an image (UI element, anatomy, map). Use when there's a visual the lesson already shows.
- **CHECKBOX (multi-select)** — when the question genuinely has multiple correct answers a learner should identify together. Don't use CHECKBOX as a "harder RADIO" — only when "select all that apply" is the natural verb.
- **RADIO (single answer)** — concept-level discrimination between mutually exclusive options. Useful but the most overused — when in doubt, ask if one of the types above fits better.
**Anti-pattern to avoid:** an exercise of 8 questions that's 7 RADIO + 1 TRUE_FALSE. That's a "different types" technicality, not real variety. The mix must reflect the *content*: numeric questions get NUMERIC, ordering questions get ORDERING, definition-matching gets MATCHING, etc.
## Plan Mode vs Agent Mode
@@ -79,7 +96,7 @@ Every Bloom outcome, lesson title, section, and exercise belongs *inside* the \`
1. Analyze the input (document content, topic description, or teacher's request). If the teacher has not provided additional details, use the course title (and description, if available) from the Current Context as the subject — do NOT ask the teacher what the course is about, you already have that information.
2. Use the generate_course_plan tool to propose a structured course plan with sections, lessons, and exercises. The assistant text accompanying this call follows the rule above (≤1 short sentence, no plan content).
3. **Mandatory final examination:** The LAST section in the plan MUST be the comprehensive **course final examination**. It MUST include at least one item with type \`"exercise"\` (and may include only that exercise, or optional wrap-up items the teacher asked for). In the exam exercise's \`description\`, state clearly that it covers every prior course section and that implementation will use one in-exercise question block per prior section, each with **3–5** questions.
3. **Final examination (default, not mandatory):** For multi-section instructional courses, the LAST section SHOULD be a comprehensive **course final examination** — one item with type \`"exercise"\` whose \`description\` states it covers every prior section and that implementation will use one in-exercise question block per prior section with **3–5** questions each. Skip the final exam when the request clearly doesn't fit it: short courses (≤3 lessons total), reference handbooks, live-class outlines where assessment happens off-platform, onboarding/SOP courses, or single-lesson revisions. Default to including it; only omit when the teacher's intent makes the exam inappropriate.
4. Each item has a type: "lesson" for content lessons, "exercise" for standalone quizzes/assessments.
5. Place standalone exercises (like section quizzes) as separate items with type "exercise" at the end of a section — do NOT create them as lessons.
6. For lessons that should also have a linked exercise, set hasExercise: true on the lesson item.
@@ -138,7 +155,7 @@ Mentally verify, then return only if all are true:
2. Every section maps to ≥1 outcome and every outcome maps to ≥1 section.
3. No compound lesson titles ("and"/comma joining concepts).
4. Section count, total lesson count, and per-lesson word-target match the chosen depth tier ranges (see Active Template Flow → Depth tier block, when a template is active).
5. The last section is the comprehensive final examination, and interleaving callbacks exist after every third section.
5. For multi-section instructional courses, the last section is the comprehensive final examination (skip per step 3 above when the request doesn't fit it). Interleaving callbacks exist after every third section.
**Agent Mode** — When the teacher approves a plan or asks you to perform a specific action:
1. Execute the requested actions using the appropriate tools
@@ -153,7 +170,11 @@ Mentally verify, then return only if all are true:
4. Report progress as you go
5. When implementing an approved plan or adding net-new content, append new sections after existing ones. Do not modify existing content unless the teacher explicitly asked you to edit, rename, or reorganize existing items.
**Tools that require an approved plan.** The following tools are not bound to the chat endpoint and will only become available once the teacher approves a plan (which spawns a background Agent-mode run): \`create_lesson\`, \`create_exercise\`, \`update_lesson_content\`, \`add_questions\`. If the teacher asks you to write lesson content, create new lessons or exercises, or add a batch of questions, you MUST first call \`generate_course_plan\` and wait for approval — do not attempt those tools in chat, they will not be present in the tool list. Reads (\`get_*\`, \`check_course_go_live_readiness\`, \`fetch_documentation_url\`), small metadata edits (\`update_lesson\`, \`update_exercise\`, \`update_section\`, \`update_exercise_section\`, \`update_questions\`), landing-page mutations, and \`go_live_course\` all remain available in chat.
**Tools that require an approved plan.** The following BULK-CREATION tools are not bound to the chat endpoint and will only become available once the teacher approves a plan (which spawns a background Agent-mode run): \`create_lesson\`, \`create_exercise\`, \`add_questions\`. They are intentionally absent from the chat tool list. If the teacher asks you to create NEW lessons or exercises or add a batch of questions, you MUST call \`generate_course_plan\` and wait for approval — **do not say these tools are unavailable**; the plan-then-run path is required for bulk creation. Reads (\`get_*\`, \`check_course_go_live_readiness\`, \`fetch_documentation_url\`), single-lesson content edits (\`update_lesson_content\`), small metadata edits (\`update_lesson\`, \`update_exercise\`, \`update_section\`, \`update_exercise_section\`, \`update_questions\`), landing-page mutations, and \`go_live_course\` all remain available in chat.
**Single-lesson edits run inline.** When the teacher asks to revise the lesson they currently have open ("make this more detailed", "shorten this", "rewrite the intro", "translate to French", "add an example about X"), call \`update_lesson_content\` directly on that lesson. Do NOT call \`generate_course_plan\` — a one-lesson revision is not a plannable bulk-creation operation and the plan schema (which requires a comprehensive final exam in the last section) does not fit. Plan + run is for creating multiple new lessons/exercises from scratch, not for editing one existing lesson.
**IMPORTANT — never block on missing run-only tools.** When a teacher asks to create NEW lessons or exercises or add a batch of questions and you notice \`create_lesson\` / \`create_exercise\` / \`add_questions\` are not in your tool list, do NOT respond with "those tools are unavailable" or "I cannot perform this action." Instead, treat it as a Plan Mode trigger: call \`generate_course_plan\` to propose a plan and wait for the teacher's approval. The background run that follows the approval is what unlocks those tools. The plan-then-approve flow IS the way to create lessons and exercises — it is not a workaround.
**Driving an approved plan to completion — do not voluntarily pause.** Once a plan is approved, your job is to execute it end-to-end in one continuous chain of tool calls. Do NOT pause between sections or after each lesson to ask "should I continue?", "ready for the next section?", "shall I proceed?", or any equivalent confirmation. The teacher's approval already covered the entire plan. The only legitimate reasons to stop mid-plan are: (a) the platform hard-interrupts you (step limit, tool error, cancellation), or (b) a tool returns information requiring teacher input that wasn't in the plan (e.g. a missing required field you genuinely cannot infer). Asking the teacher to type "continue" is a regression — drive forward.
@@ -199,7 +220,7 @@ If a lesson has \`hasExercise: true\`, the linked exercise takes the next order
To change an existing question (its text, points, order, in-exercise section, correct answer, or options), use update_questions — never use add_questions for edits, as that creates duplicates. Use \`exerciseSectionId\` on update_questions to move a question to another block from get_exercise_details \`sections[].id\`, or null to unassign. When adding questions with add_questions, pass exerciseSectionId from get_exercise_details \`sections[].id\` if the exercise has multiple in-exercise blocks so new items land in the right group. Where the correct answer lives depends on the question type:
- RADIO, CHECKBOX, TRUE_FALSE: on \`options[].isCorrect\`. Include an option's \`id\` to edit it; omit \`id\` to add a new option.
- RADIO, CHECKBOX, TRUE_FALSE: on \`options[].isCorrect\`. Include an option's \`id\` to edit it; omit \`id\` to add a new option. The system automatically balances which position holds the correct answer across an exercise, so you don't need to vary it yourself — but DO keep the correct option from being an obvious tell: don't always make it the longest or most detailed choice, vary True/False answers, and avoid leaning on "all of the above"-style options.
- NUMERIC: see the dedicated NUMERIC block below — \`settings.correctValue\` is required and \`settings.tolerance\` should almost always be set. \`options\` MUST be empty/absent.
- STAR: on \`settings.correctValue\` (1..max stars).
- WORD_BANK: on \`settings.correctAnswers\` (array, one per \`___\` blank) and \`settings.template\`.
@@ -317,12 +338,12 @@ When you create an exercise (especially during plan implementation), it must act
- For NUMERIC / STAR / WORD_BANK: still ensure the answer is unambiguously derivable from the lesson.
### Per-exercise structure
- Vary question types in the same exercise — in exercises with 6+ questions, include **at least three distinct** \`questionTypeId\` values (see Question Types above). Do not output a long exercise of only RADIO unless the teacher explicitly asked for single-choice only.
- Vary question types in the same exercise — minimum 2 distinct \`questionTypeId\` values for any 3–5 question exercise, minimum 4 for 6+ questions, with no single type exceeding half the exercise. Pick the type that matches the cognitive skill (see "Question Types" above): numeric questions get NUMERIC, ordering questions get ORDERING, definition-matching gets MATCHING — do not paper over a missing fit by defaulting to RADIO.
- Set non-zero \`points\` per question (default 1; harder questions can be 2).
### Comprehensive final examination (when implementing a full course plan)
The last course outline section is the final exam. Build **one** comprehensive exercise that contains **one in-exercise block per prior course section** (via \`create_exercise_section\`), each block with **3–5** questions tied to that section's learning outcomes, **mixed question types** across the whole exam, and plausible distractors for auto-graded items.
The last course outline section is the final exam. Build **one** comprehensive exercise that contains **one in-exercise block per prior course section** (via \`create_exercise_section\`), each block with **3–5** questions tied to that section's learning outcomes, **a wide spread of question types across the whole exam** (every available type listed in "Question Types" above should appear at least once if any question in the exam naturally fits it), and plausible distractors for auto-graded items.
## Locale
@@ -330,10 +351,13 @@ Default to locale "${context.locale}" when creating or updating lesson content.
## Confirmation Before Changes
- When drafting a new lesson, present the full draft in your response and ask the teacher to confirm before creating it
- When updating existing lesson content, show the proposed changes and ask for confirmation before applying
- For plan execution (after explicit approval), proceed without asking for confirmation on each step
- For additive actions like generating questions, execute directly and confirm after
The default is **execute, then summarize** — not propose-then-wait. A teacher chatting with the assistant about the lesson they have open expects the assistant to act, the same way they'd expect a colleague to.
- **Imperative edits on existing content — execute directly.** Requests like "make this more detailed", "shorten this", "rewrite in a friendlier tone", "add an example", "translate to French", "fix the typos", "replace section X with Y" are unambiguous instructions on the currently-open lesson/exercise. Call the relevant update tool immediately and follow with a one-sentence summary plus the lesson/exercise link (per the "Linking to Created or Updated Content" section). Do NOT post the new content as a preview and ask "Do you approve?" — the teacher can read the diff in the actual content and revert if they don't like it.
- **Speculative or exploratory requests — propose first.** Only when the teacher's request is genuinely open-ended ("what could we add to this lesson?", "draft a new lesson on X for me to look at first", "what would a tone shift look like?") should you reply with a proposal and wait for go-ahead.
- **Brand-new lessons created without a plan** — present the draft once for the teacher to glance at, then create on confirmation. (Plan-driven course generation has its own approval flow described above and bypasses this rule.)
- **Plan execution (after explicit approval)** — proceed without asking for confirmation on each step.
- **Additive actions like generating questions** — execute directly and confirm after.
## Linking to Created or Updated Content
+3 -3
View File
@@ -160,7 +160,7 @@ export const COURSE_TEMPLATES = [
coreInstructions: `You are running the Product 101 Course template. The teacher wants a fundamentals course that takes a brand-new customer from zero to confidently using their product end-to-end.
Step-by-step protocol:
1. As your very first action, call ask_template_questions with the registry fields for this template.
1. As your very first action, call ask_template_questions with only \`templateId\` for this template — the server fills in title and fields from the registry.
2. Wait for a user message with metadata.template.action === 'submit_template_answers' OR 'skip_template_form'.
3. **Immediately after submit_template_answers** (or after you have collected all answers via skip_template_form): your **first** action MUST be a single \`update_course_landing_page\` tool call — no assistant prose and no other tool before it. The course sidebar title was created from a placeholder prompt; you must replace it with real data from the submitted answers:
- \`title\`: exactly \`"{product_name} 101"\` using the trimmed \`product_name\` value from the answers (e.g. answers "Acme CRM" → title "Acme CRM 101"). Never use angle brackets, square brackets, or the words "Product name" as literals.
@@ -222,7 +222,7 @@ If the user chose 'skip_template_form', ask the questions from the registry one
coreInstructions: `You are running the Product Onboarding Training template. The teacher wants a focused, action-oriented course that gets a specific audience productive within their first week of using the product.
Step-by-step protocol:
1. As your very first action, call ask_template_questions with the registry fields for this template.
1. As your very first action, call ask_template_questions with only \`templateId\` for this template — the server fills in title and fields from the registry.
2. Wait for metadata.template.action === 'submit_template_answers' OR 'skip_template_form'.
3. **Immediately after submit_template_answers** (or after you have collected all answers via skip_template_form): your **first** action MUST be a single \`update_course_landing_page\` tool call — no assistant prose and no other tool before it. Replace the placeholder-derived course title with real data:
- \`title\`: if \`audience\` is \`new_hire\`, use \`"{product_name} Team Onboarding"\`; otherwise use \`"{product_name} Onboarding"\` — always with the trimmed \`product_name\` from answers. Never use angle brackets or the literal substring "Product name".
@@ -282,7 +282,7 @@ If 'skip_template_form', ask the registry questions one at a time in plain text
coreInstructions: `You are running the Expert on X template. The teacher wants a depth course that genuinely makes a learner an expert on a specific topic or niche, not a surface-level overview.
Step-by-step protocol:
1. As your very first action, call ask_template_questions with the registry fields for this template.
1. As your very first action, call ask_template_questions with only \`templateId\` for this template — the server fills in title and fields from the registry.
2. Wait for metadata.template.action === 'submit_template_answers' OR 'skip_template_form'.
3. **Immediately after submit_template_answers** (or after you have collected all answers via skip_template_form): your **first** action MUST be a single \`update_course_landing_page\` tool call — no assistant prose and no other tool before it. Replace the placeholder-derived course title with real data:
- \`title\`: choose ONE style using the trimmed \`topic\` from answers: either \`Becoming an Expert on {topic}\` or \`{topic}: From Practitioner to Expert\`. Use the actual topic string (e.g. "PostgreSQL performance tuning"), never angle brackets, never the word "Topic" as a placeholder, never square brackets.
+17 -3
View File
@@ -80,9 +80,23 @@ export const CoursePlanFieldsSchema = z.object({
)
});
/** Validated when calling `generate_course_plan` — enforces a final examination section. */
/**
* Course plan validated on `generate_course_plan`. The final-exam structure is
* enforced only when the plan actually looks like a multi-section course (≥2
* sections AND at least one lesson item across the whole plan). Single-lesson
* revisions, short reference handbooks, and exercise-only plans skip the check
* — they aren't shaped like "a course" and the comprehensive final exam doesn't
* fit them. When the check does apply, the last section must contain at least
* one exercise item so implementation has a target for the comprehensive exam.
*/
export const CoursePlanSchema = CoursePlanFieldsSchema.superRefine((plan, ctx) => {
const lastIndex = plan.sections.length - 1;
const sectionCount = plan.sections.length;
if (sectionCount < 2) return;
const hasAnyLesson = plan.sections.some((section) => section.items.some((item) => item.type === 'lesson'));
if (!hasAnyLesson) return;
const lastIndex = sectionCount - 1;
const lastSection = plan.sections[lastIndex];
if (!lastSection) return;
@@ -91,7 +105,7 @@ export const CoursePlanSchema = CoursePlanFieldsSchema.superRefine((plan, ctx) =
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
'The last section must include at least one exercise item for the comprehensive final examination (in implementation: one exercise with create_exercise_section blocks—one per prior course section—each with 3–5 questions).',
'For multi-section instructional courses, the last section must include at least one exercise item — the comprehensive final examination. Implement it as one exercise with create_exercise_section blocks (one per prior course section), each with 3–5 questions. Omit only for single-lesson revisions, short reference handbooks, or exercise-only plans, which should have <2 sections or contain no lesson items.',
path: ['sections', lastIndex]
});
}
+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
}
}
+7 -9
View File
@@ -19,14 +19,15 @@ import { syncUserWithProfile } from './auth/hooks/sync-user';
import { tokenExchange } from './auth/plugins/token-exchange';
import { trackLoginHook } from './auth/hooks/track-login';
export { mintLoginLinkToken } from './auth/login-link';
/**
* Cloud (multi-tenant) only. Routes OAuth/SSO callbacks to the canonical
* production URL while completing the flow on whichever tenant host the
* user signed in from (<org>.myclassroomio.com or a BYOD domain).
*
* Self-hosted instances run with one apex (api.<domain> + app.<domain>)
* and use AUTH_COOKIE_DOMAIN for cross-subdomain cookies, so the proxy
* isn't needed there.
* Self-hosted instances proxy browser auth through the dashboard origin, so
* the OAuth proxy plugin is only needed for cloud tenant/BYOD domains.
*/
function buildOAuthProxyPlugin() {
if (process.env.PUBLIC_IS_SELFHOSTED === 'true') {
@@ -74,12 +75,9 @@ export const auth: ReturnType<typeof betterAuth> = betterAuth({
},
advanced: {
cookiePrefix: 'classroomio',
// Cloud (multi-tenant): host-only cookies on each tenant/BYOD domain.
// Self-hosted: cross-subdomain cookies under AUTH_COOKIE_DOMAIN so the
// session set on `api.<apex>` is also sent to `app.<apex>`.
crossSubDomainCookies: process.env.AUTH_COOKIE_DOMAIN?.trim()
? { enabled: true, domain: process.env.AUTH_COOKIE_DOMAIN.trim() }
: { enabled: false },
// Browser auth is first-party through tenant-router or the dashboard proxy,
// so cookies should stay host-only on the dashboard/public-site origin.
crossSubDomainCookies: { enabled: false },
database: {
generateId: false
}
+44
View File
@@ -0,0 +1,44 @@
import { SignJWT } from 'jose';
/**
* Default lifetime for a minted login-link token. Login links are single-purpose
* cross-host sign-in handoffs, so they are intentionally short-lived.
*/
const DEFAULT_TTL_MINUTES = 10;
function getLoginLinkSecret(): Uint8Array {
const secret = process.env.BETTER_AUTH_SECRET?.trim();
if (!secret) {
throw new Error('BETTER_AUTH_SECRET is required for login links');
}
return new TextEncoder().encode(secret);
}
/**
* Mint a short-lived HS256 JWT consumed by the `login-link` auth plugin
* (`GET /api/auth/login-link?token=…`). The payload matches `ZLoginLinkPayload`
* (`sub` + `email` + `type`); the consumer re-verifies the user and email before
* creating a session, so callers must only ever mint for a user they have
* already authenticated (self sign-in), never an arbitrary target.
*/
export async function mintLoginLinkToken({
userId,
email,
ttlMinutes = DEFAULT_TTL_MINUTES
}: {
userId: string;
email: string;
ttlMinutes?: number;
}): Promise<string> {
return new SignJWT({
email: email.toLowerCase().trim(),
type: 'login-link'
})
.setProtectedHeader({ alg: 'HS256' })
.setSubject(userId)
.setIssuedAt()
.setExpirationTime(`${ttlMinutes}m`)
.sign(getLoginLinkSecret());
}
+18 -9
View File
@@ -1,4 +1,11 @@
const { TRUSTED_ORIGINS: TRUSTED_ORIGINS_STRING, PUBLIC_SERVER_URL } = process.env;
const {
DASHBOARD_ORIGIN,
PUBLIC_IS_SELFHOSTED,
PUBLIC_SERVER_URL,
TRUSTED_ORIGINS: TRUSTED_ORIGINS_STRING
} = process.env;
const dashboardOrigin = DASHBOARD_ORIGIN?.trim().replace(/\/$/, '');
const DEFAULT_TRUSTED_ORIGINS = [
'http://localhost:5173',
@@ -16,13 +23,15 @@ const DEFAULT_TRUSTED_ORIGINS = [
'https://login.okta.com'
];
export const TRUSTED_ORIGINS = TRUSTED_ORIGINS_STRING
? [
...DEFAULT_TRUSTED_ORIGINS,
...TRUSTED_ORIGINS_STRING.split(',')
.map((s) => s.trim())
export const TRUSTED_ORIGINS = [
...DEFAULT_TRUSTED_ORIGINS,
...(TRUSTED_ORIGINS_STRING
? TRUSTED_ORIGINS_STRING.split(',')
.map((origin) => origin.trim())
.filter(Boolean)
]
: DEFAULT_TRUSTED_ORIGINS;
: []),
...(dashboardOrigin ? [dashboardOrigin] : [])
];
export const BASE_URL = PUBLIC_SERVER_URL || 'http://localhost:3002';
export const BASE_URL =
PUBLIC_IS_SELFHOSTED === 'true' && dashboardOrigin ? dashboardOrigin : PUBLIC_SERVER_URL || 'http://localhost:3002';
@@ -253,6 +253,21 @@ export async function upsertAgentRunStep(input: UpsertAgentRunStepInput): Promis
}
}
export async function getAgentRunStep(runId: string, stepKey: string): Promise<TAiAgentRunStep | null> {
try {
const [row] = await db
.select()
.from(schema.aiAgentRunStep)
.where(and(eq(schema.aiAgentRunStep.runId, runId), eq(schema.aiAgentRunStep.stepKey, stepKey)))
.limit(1);
return row ?? null;
} catch (error) {
console.error('getAgentRunStep error:', error);
throw new Error('Failed to get agent run step');
}
}
export async function listAgentRunSteps(runId: string): Promise<TAiAgentRunStep[]> {
try {
return await db
+125
View File
@@ -217,6 +217,131 @@ export async function updateAsset(
}
}
export interface InitHlsAssetInput {
organizationId: string;
createdByProfileId: string;
title: string | null;
byteSize: number | null;
mimeType: string | null;
}
/**
* Insert an asset row in `processing` status for an HLS upload. The
* storage layout (storageKey / hlsManifestKey / hlsAudioKey) is filled in
* by finalizeHlsAsset once the browser encoder has written everything to
* R2 — we just need an id to namespace the segments under.
*/
export async function createHlsAssetPlaceholder(
input: InitHlsAssetInput,
dbClient: DbOrTxClient = db
): Promise<TAsset> {
try {
const [created] = await dbClient
.insert(schema.asset)
.values({
organizationId: input.organizationId,
kind: 'video',
provider: 'upload',
storageProvider: 's3',
title: input.title,
byteSize: input.byteSize,
mimeType: input.mimeType,
isExternal: false,
status: 'processing',
createdByProfileId: input.createdByProfileId
})
.returning();
if (!created) {
throw new Error('Failed to create HLS asset placeholder');
}
return created;
} catch (error) {
console.error('createHlsAssetPlaceholder error:', error);
throw new Error(
`Failed to create HLS asset placeholder: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export interface FinalizeHlsAssetInput {
manifestKey: string;
audioKey: string | null;
durationSeconds: number | null;
aspectRatio: string | null;
byteSize: number;
thumbnailUrl: string | null;
thumbnailCandidates: string[];
metadata: Record<string, unknown>;
}
export async function finalizeHlsAsset(
assetId: string,
orgId: string,
input: FinalizeHlsAssetInput,
dbClient: DbOrTxClient = db
): Promise<TAsset | null> {
try {
const [updated] = await dbClient
.update(schema.asset)
.set({
hlsManifestKey: input.manifestKey,
hlsAudioKey: input.audioKey,
// storageKey stays null for HLS assets so lesson-media.ts (which
// signs storageKey as the canonical video URL) doesn't overwrite
// the HLS manifest URL. The transcription enqueue path reads
// hlsAudioKey for HLS assets — see startTranscriptionOnlyMediaJob.
durationSeconds: input.durationSeconds,
aspectRatio: input.aspectRatio,
byteSize: input.byteSize,
thumbnailUrl: input.thumbnailUrl,
thumbnailCandidates: input.thumbnailCandidates,
metadata: input.metadata,
status: 'active',
updatedAt: new Date().toISOString()
})
.where(and(eq(schema.asset.id, assetId), eq(schema.asset.organizationId, orgId)))
.returning();
return updated || null;
} catch (error) {
console.error('finalizeHlsAsset error:', error);
throw new Error(`Failed to finalize HLS asset: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export interface FinalizeHls1080Input {
byteSize: number;
metadata: Record<string, unknown>;
}
export async function finalizeHls1080Rendition(
assetId: string,
orgId: string,
input: FinalizeHls1080Input,
dbClient: DbOrTxClient = db
): Promise<TAsset | null> {
try {
const [updated] = await dbClient
.update(schema.asset)
.set({
byteSize: input.byteSize,
metadata: input.metadata,
updatedAt: new Date().toISOString()
})
.where(and(eq(schema.asset.id, assetId), eq(schema.asset.organizationId, orgId)))
.returning();
return updated || null;
} catch (error) {
console.error('finalizeHls1080Rendition error:', error);
throw new Error(
`Failed to finalize HLS 1080p rendition: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function archiveAsset(
assetId: string,
orgId: string,
+19 -1
View File
@@ -56,7 +56,8 @@ export interface TStudentCourse extends TBaseCourse {
export const getPublishedCoursesBySiteName = async (
siteName: string,
courseIds?: string[],
limit?: number
limit?: number,
offset?: number
): Promise<TBaseCourse[]> => {
try {
if (courseIds && courseIds.length === 0) {
@@ -95,6 +96,7 @@ export const getPublishedCoursesBySiteName = async (
.groupBy(schema.course.id)
.orderBy(desc(schema.course.createdAt))
.limit(limit)
.offset(offset ?? 0)
: await db
.select({
course: schema.course,
@@ -300,6 +302,8 @@ export type CourseWithRelations = TCourse & {
id: string;
name: string;
siteName: string | null;
customDomain: string | null;
isCustomDomainVerified: boolean | null;
theme: string | null;
} | null;
};
@@ -342,6 +346,8 @@ export async function getCourseWithRelations(
id: string;
name: string;
siteName: string | null;
customDomain: string | null;
isCustomDomainVerified: boolean | null;
theme: string | null;
} | null;
};
@@ -362,6 +368,8 @@ export async function getCourseWithRelations(
id: schema.organization.id,
name: schema.organization.name,
siteName: schema.organization.siteName,
customDomain: schema.organization.customDomain,
isCustomDomainVerified: schema.organization.isCustomDomainVerified,
theme: schema.organization.theme
}
})
@@ -402,6 +410,8 @@ export async function getCourseWithRelations(
id: groupData[0].organization.id,
name: groupData[0].organization.name,
siteName: groupData[0].organization.siteName ?? null,
customDomain: groupData[0].organization.customDomain ?? null,
isCustomDomainVerified: groupData[0].organization.isCustomDomainVerified ?? null,
theme: groupData[0].organization.theme ?? null
}
: null;
@@ -616,6 +626,8 @@ export type TCourseCertificationRow = {
} | null;
title: string;
orgSiteName: string | null;
orgCustomDomain: string | null;
orgIsCustomDomainVerified: boolean | null;
orgName: string;
};
@@ -628,6 +640,8 @@ export async function getCourseCertificationRow(courseId: string): Promise<TCour
certificate: schema.course.certificate,
title: schema.course.title,
orgSiteName: schema.organization.siteName,
orgCustomDomain: schema.organization.customDomain,
orgIsCustomDomainVerified: schema.organization.isCustomDomainVerified,
orgName: schema.organization.name
})
.from(schema.course)
@@ -1065,6 +1079,8 @@ export async function getCourseWithOrgData(courseId: string): Promise<{
courseTitle: string | null;
orgName: string | null;
orgSiteName: string | null;
orgCustomDomain: string | null;
orgIsCustomDomainVerified: boolean | null;
groupId: string | null;
} | null> {
try {
@@ -1073,6 +1089,8 @@ export async function getCourseWithOrgData(courseId: string): Promise<{
courseTitle: schema.course.title,
orgName: schema.organization.name,
orgSiteName: schema.organization.siteName,
orgCustomDomain: schema.organization.customDomain,
orgIsCustomDomainVerified: schema.organization.isCustomDomainVerified,
groupId: schema.course.groupId
})
.from(schema.course)
+8 -2
View File
@@ -348,6 +348,8 @@ export type TCourseInviteAcceptBundle = {
id: string;
name: string;
siteName: string;
customDomain: string | null;
isCustomDomainVerified: boolean | null;
};
};
@@ -370,7 +372,9 @@ export async function selectCourseInviteAcceptBundleByTokenHash(
organization: {
id: schema.organization.id,
name: schema.organization.name,
siteName: schema.organization.siteName
siteName: schema.organization.siteName,
customDomain: schema.organization.customDomain,
isCustomDomainVerified: schema.organization.isCustomDomainVerified
}
})
.from(schema.courseInvite)
@@ -392,7 +396,9 @@ export async function selectCourseInviteAcceptBundleByTokenHash(
},
organization: {
...row.organization,
siteName: row.organization.siteName ?? ''
siteName: row.organization.siteName ?? '',
customDomain: row.organization.customDomain ?? null,
isCustomDomainVerified: row.organization.isCustomDomainVerified ?? null
}
};
} catch (error) {
@@ -295,6 +295,12 @@ export interface PublicLessonContent {
/** S3 object key for uploads; used server-side for presigning (stripped before API response). */
key?: string;
assetId?: string;
/**
* When true, `link` points at an HLS master playlist served by the
* tenant-router Worker (prod) or API streaming route (local). The
* shared media player attaches hls.js based on this flag.
*/
hls?: boolean;
transcript?: {
vttUrl: string;
vttUrlExpiresAt: string;
@@ -54,6 +54,38 @@ export async function upsertMediaTranscript(
}
}
/**
* Update only the editable content (segments + derived text) of an asset's
* transcript. Scoped by asset + organization. Returns the updated row or
* `null` when no transcript exists for the asset.
*/
export async function updateMediaTranscriptContent(
assetId: string,
organizationId: string,
patch: { segments: TMediaTranscript['segments']; text: string },
dbClient: DbOrTxClient = db
): Promise<TMediaTranscript | null> {
try {
const now = new Date().toISOString();
const [row] = await dbClient
.update(schema.mediaTranscript)
.set({
segments: patch.segments,
text: patch.text,
updatedAt: now
})
.where(
and(eq(schema.mediaTranscript.assetId, assetId), eq(schema.mediaTranscript.organizationId, organizationId))
)
.returning();
return row ?? null;
} catch (error) {
console.error('updateMediaTranscriptContent error:', error);
throw new Error('Failed to update media transcript content');
}
}
export async function getMediaTranscriptByAsset(
assetId: string,
organizationId: string,
@@ -1,5 +1,6 @@
export * from './organization';
export * from './quiz';
export * from './invite';
export * from './link-invite';
export * from './automation-key';
export * from './automation-usage';
@@ -257,10 +257,12 @@ export async function getLatestOrgInvitesByEmails(
const latestByEmail = new Map<string, TLatestOrgInviteByEmail>();
for (const row of rows) {
if (!row.email) continue;
const key = row.email.toLowerCase();
const existing = latestByEmail.get(key);
if (!existing || new Date(row.createdAt) > new Date(existing.createdAt)) {
latestByEmail.set(key, row);
latestByEmail.set(key, { ...row, email: row.email });
}
}
@@ -0,0 +1,117 @@
import * as schema from '@db/schema';
import { and, eq, isNull } from 'drizzle-orm';
import { db, type DbOrTxClient } from '@db/drizzle';
import type { TOrganizationInvite } from '@db/types';
export type TLinkInviteRow = TOrganizationInvite & {
organization: { id: string; name: string; siteName: string };
};
export async function getOrgLinkInvite(organizationId: string): Promise<TOrganizationInvite | null> {
try {
const [row] = await db
.select()
.from(schema.organizationInvite)
.where(
and(
eq(schema.organizationInvite.organizationId, organizationId),
eq(schema.organizationInvite.type, 'LINK'),
eq(schema.organizationInvite.isRevoked, false)
)
)
.limit(1);
return row ?? null;
} catch (error) {
console.error('getOrgLinkInvite error:', error);
throw new Error(`Failed to get org link invite: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function createLinkInvite(data: {
organizationId: string;
roleId: number;
tokenHash: string;
createdByProfileId: string;
expiresAt: string;
metadata: Record<string, unknown>;
}): Promise<TOrganizationInvite> {
try {
const [created] = await db
.insert(schema.organizationInvite)
.values({
...data,
type: 'LINK',
email: null,
isRevoked: false
})
.returning();
if (!created) {
throw new Error('Failed to create link invite');
}
return created;
} catch (error) {
console.error('createLinkInvite error:', error);
throw new Error(`Failed to create link invite: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function setLinkInviteRevoked(
organizationId: string,
isRevoked: boolean,
profileId: string
): Promise<TOrganizationInvite | null> {
try {
const [updated] = await db
.update(schema.organizationInvite)
.set({
isRevoked,
revokedByProfileId: isRevoked ? profileId : null,
revokedAt: isRevoked ? new Date().toISOString() : null,
updatedAt: new Date().toISOString()
})
.where(
and(eq(schema.organizationInvite.organizationId, organizationId), eq(schema.organizationInvite.type, 'LINK'))
)
.returning();
return updated ?? null;
} catch (error) {
console.error('setLinkInviteRevoked error:', error);
throw new Error(`Failed to update link invite: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getOrgLinkInviteWithOrg(
dbClient: DbOrTxClient,
tokenHash: string
): Promise<{ invite: TOrganizationInvite; organization: { id: string; name: string; siteName: string } } | null> {
try {
const [row] = await dbClient
.select({
invite: schema.organizationInvite,
organization: {
id: schema.organization.id,
name: schema.organization.name,
siteName: schema.organization.siteName
}
})
.from(schema.organizationInvite)
.innerJoin(schema.organization, eq(schema.organizationInvite.organizationId, schema.organization.id))
.where(and(eq(schema.organizationInvite.tokenHash, tokenHash), eq(schema.organizationInvite.type, 'LINK')))
.limit(1);
if (!row) return null;
return {
...row,
organization: { ...row.organization, siteName: row.organization.siteName ?? '' }
};
} catch (error) {
console.error('getOrgLinkInviteWithOrg error:', error);
throw new Error(`Failed to load link invite: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
@@ -913,9 +913,7 @@ export const getOrganizationPlanBySubscriptionId = async (
return plan ?? null;
} catch (error) {
console.error('getOrganizationPlanBySubscriptionId error:', error);
throw new Error(
`Failed to fetch organization plan: ${error instanceof Error ? error.message : 'Unknown error'}`
);
throw new Error(`Failed to fetch organization plan: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
+8 -2
View File
@@ -104,6 +104,8 @@ export async function listAssignmentsForReminderScan(): Promise<
programName: string;
organizationName: string;
organizationSiteName: string | null;
organizationCustomDomain: string | null;
organizationIsCustomDomainVerified: boolean | null;
}>
> {
try {
@@ -116,7 +118,9 @@ export async function listAssignmentsForReminderScan(): Promise<
programId: schema.program.id,
programName: schema.program.name,
organizationName: schema.organization.name,
organizationSiteName: schema.organization.siteName
organizationSiteName: schema.organization.siteName,
organizationCustomDomain: schema.organization.customDomain,
organizationIsCustomDomainVerified: schema.organization.isCustomDomainVerified
})
.from(schema.programGoalAssignment)
.innerJoin(schema.programMember, eq(schema.programGoalAssignment.programMemberId, schema.programMember.id))
@@ -140,7 +144,9 @@ export async function listAssignmentsForReminderScan(): Promise<
programId: row.programId,
programName: row.programName,
organizationName: row.organizationName,
organizationSiteName: row.organizationSiteName
organizationSiteName: row.organizationSiteName,
organizationCustomDomain: row.organizationCustomDomain,
organizationIsCustomDomainVerified: row.organizationIsCustomDomainVerified
}));
} catch (error) {
console.error('listAssignmentsForReminderScan error:', error);
+7 -1
View File
@@ -68,6 +68,7 @@ export const organizationInviteEventType = pgEnum('ORGANIZATION_INVITE_EVENT_TYP
'EMAIL_FAILED',
'ABUSE_BLOCKED'
]);
export const organizationInviteType = pgEnum('ORGANIZATION_INVITE_TYPE', ['EMAIL', 'LINK']);
export const user = pgTable('user', {
id: uuid()
@@ -677,6 +678,8 @@ export const course = pgTable(
created_at: number;
description: string;
}[];
skills?: string[];
tools?: string[];
lessonTabsOrder?: {
id: 1 | 2 | 3 | 4;
name: string;
@@ -1034,6 +1037,8 @@ export const asset = pgTable(
provider: varchar().default('upload').notNull(),
storageProvider: varchar('storage_provider').default('s3').notNull(),
storageKey: text('storage_key'),
hlsManifestKey: text('hls_manifest_key'),
hlsAudioKey: text('hls_audio_key'),
sourceUrl: text('source_url'),
mimeType: text('mime_type'),
byteSize: bigint('byte_size', { mode: 'number' }),
@@ -1386,7 +1391,8 @@ export const organizationInvite = pgTable(
organizationId: uuid('organization_id').notNull(),
// Role assigned when invite is accepted (e.g ADMIN, TUTOR)
roleId: bigint('role_id', { mode: 'number' }).notNull(),
email: text().notNull(),
type: organizationInviteType('type').default('EMAIL').notNull(),
email: text(),
tokenHash: text('token_hash').notNull(),
createdByProfileId: uuid('created_by_profile_id').notNull(),
acceptedByProfileId: uuid('accepted_by_profile_id'),
+5 -19
View File
@@ -3,8 +3,8 @@ import 'dotenv/config';
import * as schema from '../schema';
import { eq } from 'drizzle-orm';
import { SignJWT } from 'jose';
import { db } from '../drizzle';
import { mintLoginLinkToken } from '../auth/login-link';
const LOGIN_LINK_TTL_MINUTES = 10;
@@ -12,16 +12,6 @@ function getBaseUrl(): string {
return process.env.PUBLIC_SERVER_URL?.trim() || 'http://localhost:3002';
}
function getSecret(): Uint8Array {
const secret = process.env.BETTER_AUTH_SECRET?.trim();
if (!secret) {
throw new Error('BETTER_AUTH_SECRET is required');
}
return new TextEncoder().encode(secret);
}
function getArgs() {
const email = process.argv[2]?.trim().toLowerCase();
const redirect = process.argv[3]?.trim() || '/';
@@ -42,15 +32,11 @@ async function main() {
throw new Error(`User not found for email: ${email}`);
}
const token = await new SignJWT({
const token = await mintLoginLinkToken({
userId: user.id,
email,
type: 'login-link'
})
.setProtectedHeader({ alg: 'HS256' })
.setSubject(user.id)
.setIssuedAt()
.setExpirationTime(`${LOGIN_LINK_TTL_MINUTES}m`)
.sign(getSecret());
ttlMinutes: LOGIN_LINK_TTL_MINUTES
});
const url = new URL('/api/auth/login-link', getBaseUrl());
url.searchParams.set('token', token);
@@ -0,0 +1,25 @@
import type { JobsOptions } from 'bullmq';
import { JOB_NAMES, QUEUE_NAMES } from '../queues/names';
import { QUEUE_DEFAULTS } from '../queues/defaults';
import { getQueue } from '../queues/factories';
import type { TAgentCourseGenerationPayload } from '../payloads/agent-course-generation';
function toJobId(runId: string, attempt: number): string {
return `agent-course-generation-${runId}-${attempt}`;
}
export async function enqueueAgentCourseGenerationRun(
payload: TAgentCourseGenerationPayload,
options: { attempt: number } & JobsOptions
): Promise<string | undefined> {
const { attempt, ...jobOptions } = options;
const job = await getQueue(QUEUE_NAMES.agentCourseGeneration).add(JOB_NAMES.agentCourseGeneration.run, payload, {
...QUEUE_DEFAULTS[QUEUE_NAMES.agentCourseGeneration],
jobId: toJobId(payload.runId, attempt),
...jobOptions
});
return job.id;
}
+1
View File
@@ -1,2 +1,3 @@
export * from './media';
export * from './emails';
export * from './agent-course-generation';
@@ -0,0 +1,15 @@
import * as z from 'zod';
/**
* Payload for long-running AI course-generation runs.
*
* The worker re-loads the full run from Postgres by id. Keep the queue payload
* tiny so retries always use the latest durable run state and checkpoints.
*/
export const ZAgentCourseGenerationPayload = z.object({
runId: z.string().uuid(),
requestedByUserId: z.string().uuid(),
organizationId: z.string().uuid()
});
export type TAgentCourseGenerationPayload = z.infer<typeof ZAgentCourseGenerationPayload>;
+1
View File
@@ -1,3 +1,4 @@
export * from './media';
export * from './emails';
export * from './maintenance';
export * from './agent-course-generation';
@@ -23,6 +23,15 @@ export type TDeadLetterCleanupPayload = z.infer<typeof ZDeadLetterCleanupPayload
* stops blocking new runs after a worker crash, server restart, or lost
* BullMQ job.
*/
/**
* Daily aggregation of raw analytics page events into the daily rollup tables.
* Defaults to rolling up yesterday's data.
*/
export const ZAnalyticsDailyRollupPayload = z.object({
daysAgo: z.number().int().positive().default(1)
});
export type TAnalyticsDailyRollupPayload = z.infer<typeof ZAnalyticsDailyRollupPayload>;
export const ZMediaJobReapPayload = z.object({
staleAfterMinutes: z.number().int().positive().default(30)
});
+6
View File
@@ -47,6 +47,12 @@ export const QUEUE_DEFAULTS: Record<QueueName, JobsOptions> = {
removeOnComplete: { age: 7 * 86_400, count: 500 },
removeOnFail: { age: 14 * 86_400 }
},
[QUEUE_NAMES.agentCourseGeneration]: {
attempts: 3,
backoff: { type: 'exponential', delay: 60_000 },
removeOnComplete: { age: 7 * 86_400, count: 1_000 },
removeOnFail: { age: 14 * 86_400 }
},
[QUEUE_NAMES.onboardingBootstrap]: {
attempts: 3,
backoff: { type: 'exponential', delay: 30_000 },
+6 -1
View File
@@ -9,6 +9,7 @@ export const QUEUE_NAMES = {
notifications: 'notifications',
webhooks: 'webhooks',
courseImports: 'course-imports',
agentCourseGeneration: 'agent-course-generation',
onboardingBootstrap: 'onboarding-bootstrap',
maintenance: 'maintenance'
} as const;
@@ -35,9 +36,13 @@ export const JOB_NAMES = {
webhooks: {
deliver: 'deliver'
},
agentCourseGeneration: {
run: 'run'
},
maintenance: {
retentionCompact: 'retention-compact',
deadLetterCleanup: 'dead-letter-cleanup',
mediaJobReap: 'media-job-reap'
mediaJobReap: 'media-job-reap',
analyticsDailyRollup: 'analytics-daily-rollup'
}
} as const;
@@ -0,0 +1,89 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import {
MinimalCourseLanding,
BoldCourseLanding,
ClassicCourseLanding,
SaasCourseLanding,
TechCourseLanding,
StudioCourseLanding,
CorporateCourseLanding,
TerminalCourseLanding,
EditorialCourseLanding,
VibrantCourseLanding,
mockCourseLandingPageProps as mockProps
} from '@cio/ui/custom/org-landing-page';
const { Story } = defineMeta({
title: 'Templates/Course Landing Page',
parameters: {
layout: 'fullscreen'
},
tags: ['autodocs']
});
</script>
<Story name="Minimal">
<MinimalCourseLanding {...mockProps} />
</Story>
<Story name="Bold">
<BoldCourseLanding {...mockProps} />
</Story>
<Story name="Classic">
<ClassicCourseLanding {...mockProps} />
</Story>
<Story name="SaaS">
<SaasCourseLanding {...mockProps} />
</Story>
<Story name="Tech">
<TechCourseLanding {...mockProps} />
</Story>
<Story name="Studio">
<StudioCourseLanding {...mockProps} />
</Story>
<Story name="Corporate">
<CorporateCourseLanding {...mockProps} />
</Story>
<Story name="Terminal">
<TerminalCourseLanding {...mockProps} />
</Story>
<Story name="Editorial">
<EditorialCourseLanding {...mockProps} />
</Story>
<Story name="Vibrant">
<VibrantCourseLanding {...mockProps} />
</Story>
<Story name="Gallery · all themes">
<div class="ui:flex ui:flex-col">
<div class="ui:sticky ui:top-0 ui:z-50 ui:bg-black/85 ui:text-white ui:text-xs ui:px-4 ui:py-2">Minimal</div>
<MinimalCourseLanding {...mockProps} />
<div class="ui:sticky ui:top-0 ui:z-50 ui:bg-black/85 ui:text-white ui:text-xs ui:px-4 ui:py-2">Bold</div>
<BoldCourseLanding {...mockProps} />
<div class="ui:sticky ui:top-0 ui:z-50 ui:bg-black/85 ui:text-white ui:text-xs ui:px-4 ui:py-2">Classic</div>
<ClassicCourseLanding {...mockProps} />
<div class="ui:sticky ui:top-0 ui:z-50 ui:bg-black/85 ui:text-white ui:text-xs ui:px-4 ui:py-2">SaaS</div>
<SaasCourseLanding {...mockProps} />
<div class="ui:sticky ui:top-0 ui:z-50 ui:bg-black/85 ui:text-white ui:text-xs ui:px-4 ui:py-2">Tech</div>
<TechCourseLanding {...mockProps} />
<div class="ui:sticky ui:top-0 ui:z-50 ui:bg-black/85 ui:text-white ui:text-xs ui:px-4 ui:py-2">Studio</div>
<StudioCourseLanding {...mockProps} />
<div class="ui:sticky ui:top-0 ui:z-50 ui:bg-black/85 ui:text-white ui:text-xs ui:px-4 ui:py-2">Corporate</div>
<CorporateCourseLanding {...mockProps} />
<div class="ui:sticky ui:top-0 ui:z-50 ui:bg-black/85 ui:text-white ui:text-xs ui:px-4 ui:py-2">Terminal</div>
<TerminalCourseLanding {...mockProps} />
<div class="ui:sticky ui:top-0 ui:z-50 ui:bg-black/85 ui:text-white ui:text-xs ui:px-4 ui:py-2">Editorial</div>
<EditorialCourseLanding {...mockProps} />
<div class="ui:sticky ui:top-0 ui:z-50 ui:bg-black/85 ui:text-white ui:text-xs ui:px-4 ui:py-2">Vibrant</div>
<VibrantCourseLanding {...mockProps} />
</div>
</Story>
+16
View File
@@ -25,6 +25,22 @@ packages/ui/
└── jsrepo.json # jsrepo configuration
```
## Storybook
We maintain a Storybook instance at `packages/storybook` to make it easy to browse, visualize, and reuse every UI component in the app. Instead of digging through source code or guessing how a component looks, you can see each piece rendered in isolation with its variants, props, and states documented.
This is especially useful when you're building a new feature and need to find the right existing component — check Storybook first before creating something from scratch.
<img alt="Storybook Preview" src="https://brand.cdn.clsrio.com/storybook-preview.png" />
To run it locally:
```bash
cd packages/storybook && pnpm dev
```
See `packages/storybook/README.md` for detailed instructions on creating stories and publishing.
## Content Structure
### Base Components (`src/base/`)
+2 -1
View File
@@ -68,6 +68,7 @@
"d3-array": "^3.2.4",
"d3-scale": "^4.0.2",
"dompurify": "^3.3.0",
"hls.js": "^1.6.16",
"katex": "^0.16.25",
"lowlight": "^3.3.0",
"plyr": "^3.7.8",
@@ -79,8 +80,8 @@
"tiptap-extension-auto-joiner": "^0.1.3"
},
"peerDependencies": {
"bits-ui": "^2.14.4",
"@lucide/svelte": "^0.545.0 || ^0.562.0",
"bits-ui": "^2.14.4",
"svelte-inview": "^4.0.4",
"svelte-motion": "^0.12.2"
}
@@ -25,7 +25,11 @@
bind:ref
data-slot="dialog-content"
class={cn(
'ui:bg-background ui:border ui:data-[state=open]:animate-in ui:data-[state=closed]:animate-out ui:data-[state=closed]:fade-out-0 ui:data-[state=open]:fade-in-0 ui:data-[state=closed]:zoom-out-95 ui:data-[state=open]:zoom-in-95 ui:fixed ui:left-[50%] ui:top-[50%] ui:z-200 ui:grid ui:w-full ui:max-w-[calc(100%-2rem)] ui:translate-x-[-50%] ui:translate-y-[-50%] ui:gap-4 ui:rounded-lg ui:p-6 ui:shadow-lg ui:duration-200 ui:sm:max-w-lg',
// `has-[[data-slot=dialog-footer][data-sticky=true]]:pb-0` drops the
// dialog's own bottom padding when a sticky footer is rendered —
// the footer's own `py-4` provides the bottom spacing instead, and
// it pins flush with the dialog's bottom edge.
'ui:bg-background ui:border ui:data-[state=open]:animate-in ui:data-[state=closed]:animate-out ui:data-[state=closed]:fade-out-0 ui:data-[state=open]:fade-in-0 ui:data-[state=closed]:zoom-out-95 ui:data-[state=open]:zoom-in-95 ui:fixed ui:left-[50%] ui:top-[50%] ui:z-200 ui:grid ui:w-full ui:max-w-[calc(100%-2rem)] ui:translate-x-[-50%] ui:translate-y-[-50%] ui:gap-4 ui:rounded-lg ui:p-6 ui:[&:has([data-slot=dialog-footer][data-sticky=true])]:pb-0 ui:shadow-lg ui:duration-200 ui:sm:max-w-lg',
className
)}
{...restProps}
@@ -5,15 +5,33 @@
let {
ref = $bindable(null),
class: className,
sticky = false,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
/**
* Pin the footer to the bottom of the scrolling `Dialog.Content` so
* the primary action stays visible in tall dialogs. We use plain
* `bottom: 0` (no negative offset) because `overflow-y-auto` clips
* sticky children at the padding box — pushing the footer past that
* line makes it disappear entirely. A top border + solid background
* separates the footer from the scrolling content behind it; negative
* horizontal margins bleed past Content's `px-6` so the border spans
* edge-to-edge.
*/
sticky?: boolean;
} = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-footer"
class={cn('ui:flex ui:flex-col-reverse ui:gap-2 ui:sm:flex-row ui:sm:justify-end', className)}
data-sticky={sticky ? 'true' : undefined}
class={cn(
'ui:flex ui:flex-col-reverse ui:gap-2 ui:sm:flex-row ui:sm:justify-end',
sticky && 'ui:bg-background ui:sticky ui:bottom-0 ui:z-10 ui:-mx-6 ui:-mb-6 ui:border-t ui:px-6 ui:py-4',
className
)}
{...restProps}
>
{@render children?.()}
+4 -1
View File
@@ -13,7 +13,10 @@
<div
bind:this={ref}
data-slot="page"
class={cn('ui:flex ui:flex-col ui:gap-4 ui:min-h-[calc(100vh-48px)] ui:w-full', className)}
class={cn(
'ui:flex ui:flex-col ui:gap-4 ui:min-h-[calc(100vh-48px)] ui:w-full ui:overflow-x-hidden ui:md:overflow-x-auto',
className
)}
{...restProps}
>
{@render children?.()}
@@ -24,6 +24,8 @@
)}
{...restProps}
>
{@render children?.()}
<span class="ui:flex ui:min-w-0 ui:flex-1 ui:items-center ui:gap-2 ui:truncate ui:text-left">
{@render children?.()}
</span>
<ChevronDownIcon class="ui:size-4 ui:opacity-50" />
</SelectPrimitive.Trigger>
@@ -1,6 +1,13 @@
<script module lang="ts">
let pending: Promise<typeof import('../../../editor')> | null = null;
function loadEditor() {
pending ??= import('../../../editor');
return pending;
}
</script>
<script lang="ts">
import type { ExerciseQuestionRendererProps } from '@cio/question-types';
import { Editor } from '../../../editor';
let { question, answer = null }: ExerciseQuestionRendererProps = $props();
@@ -8,5 +15,9 @@
</script>
<div class="ui:space-y-2">
<Editor {content} editable={false} showToolBar={false} editorClass="ui:h-40" />
{#await loadEditor()}
<div class="ui:animate-pulse ui:rounded-md ui:bg-muted ui:h-40 ui:w-full" />
{:then { Editor }}
<Editor {content} editable={false} showToolBar={false} editorClass="ui:h-40" />
{/await}
</div>
@@ -1,3 +1,11 @@
<script module lang="ts">
let pending: Promise<typeof import('../../../editor')> | null = null;
function loadEditor() {
pending ??= import('../../../editor');
return pending;
}
</script>
<script lang="ts">
import { onDestroy } from 'svelte';
import type { Editor } from '@tiptap/core';
@@ -7,7 +15,6 @@
getTextareaCharacterLimits,
type ExerciseQuestionRendererProps
} from '@cio/question-types';
import { Editor as EditorComponent } from '../../../editor';
let {
question,
@@ -128,15 +135,19 @@
</script>
<div class="ui:space-y-2">
<EditorComponent
{content}
editable={!disabled}
showToolBar={!disabled}
placeholder={label('textarea.take.placeholder')}
editorClass="ui:h-40"
onContentChange={updateAnswer}
onEditorReady={handleEditorReady}
/>
{#await loadEditor()}
<div class="ui:animate-pulse ui:rounded-md ui:bg-muted ui:h-40 ui:w-full" />
{:then { Editor: EditorComponent }}
<EditorComponent
{content}
editable={!disabled}
showToolBar={!disabled}
placeholder={label('textarea.take.placeholder')}
editorClass="ui:h-40"
onContentChange={updateAnswer}
onEditorReady={handleEditorReady}
/>
{/await}
{#if shouldShowCharacterCount}
<span class="ui:text-muted-foreground ui:block ui:text-right ui:text-xs">
@@ -11,6 +11,18 @@
let { source, options = {}, class: className = '' }: Props = $props();
let plyrRef: ReturnType<typeof PlyrPlayer> | null = $state(null);
/**
* Update the player's poster image after construction. Plyr doesn't
* react to `<video poster>` attribute changes once it has wrapped the
* element; callers (e.g. the manage-thumbnails dialog) invoke this on
* thumbnail selection to refresh the preview.
*/
export function setPoster(url: string): void {
plyrRef?.setPoster(url);
}
const tracks = $derived(source.tracks ?? []);
const isMuse = $derived.by(() => source.type === 'muse' && source.metadata?.svid);
const isGoogleDrive = $derived(source.type === 'google_drive');
@@ -40,6 +52,6 @@
></iframe>
</div>
{:else}
<PlyrPlayer src={source.url} {poster} {options} {tracks} />
<PlyrPlayer bind:this={plyrRef} src={source.url} {poster} hls={source.hls === true} {options} {tracks} />
{/if}
</div>
@@ -16,10 +16,10 @@
</script>
{#if embedUrl}
<div style="position:relative;padding-bottom:51.416579%">
<div class="ui:relative ui:overflow-hidden ui:rounded-md" style="padding-bottom:51.416579%">
<iframe
src={embedUrl}
style="width:100%;height:100%;position:absolute;left:0;top:0"
class="ui:absolute ui:left-0 ui:top-0 ui:h-full ui:w-full"
frameborder="0"
allowfullscreen
title="Muse AI Video"
@@ -5,26 +5,51 @@
import type { MediaPlayerOptions, VideoTextTrack } from '../types';
import { getYoutubeVideoId, isYoutubeUrl } from '../utils';
import { PLYR_DEFAULT_CONTROLS } from './constants';
import { Button } from '../../../base/button';
import type Plyr from 'plyr';
interface Props {
src: string;
poster?: string;
/**
* When true, `src` is an HLS master playlist (.m3u8). The player
* attaches hls.js (or uses native HLS on Safari) instead of setting
* the URL on the `<video>` element directly. Cookie minting (if any)
* happens via `options.onBeforeHlsLoad`.
*/
hls?: boolean;
options?: MediaPlayerOptions;
tracks?: VideoTextTrack[];
}
let { src, poster, options = {}, tracks = [] }: Props = $props();
let { src, poster, hls = false, options = {}, tracks = [] }: Props = $props();
let hlsInstance = $state<import('hls.js').default | null>(null);
let videoElement = $state<HTMLVideoElement | null>(null);
let containerElement = $state<HTMLDivElement | null>(null);
let playerInstance = $state<Plyr | null>(null);
let transcriptButtonElement = $state<HTMLButtonElement | null>(null);
let playbackFailed = $state(false);
let reloadInFlight = $state(false);
let timeupdateCleanup: (() => void) | undefined;
let firstPlayCleanup: (() => void) | undefined;
let transcriptPanelCleanup: (() => void) | undefined;
let mediaErrorCleanup: (() => void) | undefined;
let plyrErrorCleanup: (() => void) | undefined;
let loadedSrc = $state<string | null>(null);
let hlsLoadGeneration = 0;
let hlsPlyrGeneration = 0;
let plyrConstructInFlight = false;
let hasFiredFirstPlay = false;
interface HlsQualityConfig {
default: number;
options: number[];
forced: boolean;
onChange: (height: number) => void;
}
const playsinline = $derived(options.playsinline !== false);
const maxHeight = $derived(options.maxHeight || '400px');
const showControls = $derived(options.controls !== false);
@@ -54,6 +79,52 @@
};
}
function handlePlaybackFailure(): void {
if (playbackFailed) return;
playbackFailed = true;
}
async function handlePlaybackReloadClick(): Promise<void> {
if (reloadInFlight || !options.onPlaybackReload) return;
reloadInFlight = true;
try {
const reloaded = await options.onPlaybackReload();
if (!reloaded) return;
playbackFailed = false;
if (videoElement && !isYouTube) {
loadedSrc = null;
videoElement.load();
}
} finally {
reloadInFlight = false;
}
}
function attachMediaErrorHandler(element: HTMLVideoElement): () => void {
const handler = () => {
handlePlaybackFailure();
};
element.addEventListener('error', handler);
return () => {
element.removeEventListener('error', handler);
};
}
function attachPlyrErrorHandler(player: Plyr): () => void {
const handler = () => {
handlePlaybackFailure();
};
player.on('error', handler);
return () => {
player.off('error', handler);
};
}
function attachTranscriptPanelControl(
player: Plyr,
template: HTMLButtonElement,
@@ -88,13 +159,110 @@
};
}
function teardownPlyrInstance(): void {
timeupdateCleanup?.();
firstPlayCleanup?.();
transcriptPanelCleanup?.();
plyrErrorCleanup?.();
mediaErrorCleanup?.();
timeupdateCleanup = undefined;
firstPlayCleanup = undefined;
transcriptPanelCleanup = undefined;
plyrErrorCleanup = undefined;
mediaErrorCleanup = undefined;
if (!playerInstance) return;
try {
playerInstance.destroy();
} catch {
// Plyr may be mid-teardown if the user closed the dialog.
} finally {
playerInstance = null;
}
}
async function constructPlyrForVideoElement(qualityConfig?: HlsQualityConfig): Promise<void> {
if (!videoElement || playerInstance || plyrConstructInFlight) return;
plyrConstructInFlight = true;
const PlyrModule = await import('plyr');
const PlyrConstructor = PlyrModule.default;
const controls = showControls ? PLYR_DEFAULT_CONTROLS : [];
try {
// Recheck after the dynamic import resolves — the component may have
// unmounted (or the videoElement bind nulled out) while we awaited.
if (!videoElement || playerInstance) return;
mediaErrorCleanup = attachMediaErrorHandler(videoElement);
playerInstance = new PlyrConstructor(videoElement, {
controls,
autoplay,
// Match the YouTube branch: force 16:9 so non-16:9 sources (e.g.
// screen recordings at 4:3 or 5:4) get letterboxed inside the player
// container rather than overflowing it and clipping the controls.
ratio: '16:9',
iconUrl: '/plyr.svg',
iconPrefix: 'plyr',
...(qualityConfig
? {
quality: qualityConfig,
i18n: { qualityLabel: { 0: 'Auto' } }
}
: {})
});
timeupdateCleanup = attachTimeUpdates(playerInstance);
firstPlayCleanup = attachFirstPlay(playerInstance);
plyrErrorCleanup = attachPlyrErrorHandler(playerInstance);
options.onPlayerReady?.(playerInstance);
} finally {
plyrConstructInFlight = false;
}
}
async function initHlsPlyr(hls: import('hls.js').default, loadGeneration: number): Promise<void> {
if (loadGeneration !== hlsLoadGeneration || hlsInstance !== hls) return;
if (hlsPlyrGeneration === loadGeneration) return;
hlsPlyrGeneration = loadGeneration;
const heights = Array.from(new Set(hls.levels.map((level) => level.height).filter((height) => height > 0))).sort(
(a, b) => a - b
);
if (loadGeneration !== hlsLoadGeneration || hlsInstance !== hls || !videoElement) return;
if (!heights.length) {
await constructPlyrForVideoElement();
return;
}
await constructPlyrForVideoElement({
default: 0,
options: [0, ...heights],
forced: true,
onChange: (height: number) => {
if (hlsInstance !== hls) return;
if (height === 0) {
hls.currentLevel = -1;
return;
}
const levelIndex = hls.levels.findIndex((level) => level.height === height);
if (levelIndex >= 0) hls.currentLevel = levelIndex;
}
});
}
onMount(() => {
void (async () => {
const PlyrModule = await import('plyr');
const PlyrConstructor = PlyrModule.default;
const controls = showControls ? PLYR_DEFAULT_CONTROLS : [];
if (isYouTube && youtubeVideoId && containerElement) {
const PlyrModule = await import('plyr');
const PlyrConstructor = PlyrModule.default;
const controls = showControls ? PLYR_DEFAULT_CONTROLS : [];
playerInstance = new PlyrConstructor(containerElement, {
controls,
autoplay,
@@ -104,57 +272,202 @@
});
timeupdateCleanup = attachTimeUpdates(playerInstance);
firstPlayCleanup = attachFirstPlay(playerInstance);
plyrErrorCleanup = attachPlyrErrorHandler(playerInstance);
options.onPlayerReady?.(playerInstance);
return;
}
if (videoElement) {
playerInstance = new PlyrConstructor(videoElement, {
controls,
autoplay,
// Native files expose their own width/height — let Plyr fit to them instead of
// forcing 16:9 and showing black letterbox bars around smaller/portrait videos.
iconUrl: '/plyr.svg',
iconPrefix: 'plyr'
});
timeupdateCleanup = attachTimeUpdates(playerInstance);
firstPlayCleanup = attachFirstPlay(playerInstance);
if (options.transcriptPanelControl && transcriptButtonElement) {
transcriptPanelCleanup = attachTranscriptPanelControl(
playerInstance,
transcriptButtonElement,
options.transcriptPanelControl
);
}
options.onPlayerReady?.(playerInstance);
// HLS sources defer Plyr until hls.js parses the manifest — Plyr's
// quality menu must be configured at construction time, and tearing
// the player down after hls.js attaches breaks the video wrapper.
if (!hls && videoElement) {
await constructPlyrForVideoElement();
}
})();
});
onDestroy(() => {
timeupdateCleanup?.();
firstPlayCleanup?.();
transcriptPanelCleanup?.();
if (!playerInstance) return;
/**
* Imperative poster setter. Plyr stores the poster as its own internal
* property after construction, so updating `<video poster>` reactively
* has no visible effect. Callers update the poster via this method.
* Exposed through `bind:this` on `MediaPlayer`.
*/
export function setPoster(url: string): void {
if (!playerInstance || isYouTube) return;
try {
if (playerInstance.isEmbed && playerInstance.playing) {
playerInstance.pause();
}
playerInstance.poster = url;
} catch {
// No-op for player teardown safety.
// Plyr may not have finished its setup yet — caller can retry.
}
}
$effect(() => {
const element = videoElement;
const nextSrc = src;
if (!element || isYouTube || !nextSrc || nextSrc === loadedSrc) return;
loadedSrc = nextSrc;
playbackFailed = false;
const onLoadedMetadata = () => {
element.removeEventListener('loadedmetadata', onLoadedMetadata);
options.onSourceLoaded?.(element);
};
element.addEventListener('loadedmetadata', onLoadedMetadata);
if (hls) {
void loadHlsSource(element, nextSrc, onLoadedMetadata);
} else {
element.src = nextSrc;
element.load();
}
});
function destroyHlsInstance(): void {
if (!hlsInstance) return;
try {
playerInstance.destroy();
hlsInstance.destroy();
} catch {
// ignore — hls.js may already be torn down
} finally {
playerInstance = null;
hlsInstance = null;
}
}
async function loadHlsSource(element: HTMLVideoElement, manifestUrl: string, onLoaded: () => void): Promise<void> {
const loadGeneration = ++hlsLoadGeneration;
hlsPlyrGeneration = 0;
destroyHlsInstance();
teardownPlyrInstance();
playbackFailed = false;
if (options.onBeforeHlsLoad) {
try {
await options.onBeforeHlsLoad();
} catch (error) {
// The cookie endpoint may 503 in environments where HLS_SIGNING_SECRET
// isn't configured (local dev / self-hosted). Playback can still
// succeed via the API's session-auth streaming route — so we log and
// proceed rather than fail.
console.warn('onBeforeHlsLoad failed, continuing without cookie', error);
}
}
// Bail if the source changed underneath us while awaiting the cookie.
if (loadGeneration !== hlsLoadGeneration || loadedSrc !== manifestUrl) {
element.removeEventListener('loadedmetadata', onLoaded);
return;
}
// Prefer hls.js over native HLS. Chrome's `canPlayType` returns
// `'maybe'` for `application/vnd.apple.mpegurl` even though it can't
// actually play HLS — taking the native path on Chrome makes the
// `<video>` element fetch the manifest directly with
// `crossorigin="anonymous"`, which strips cookies and trips the api's
// 403. Only fall back to native HLS when hls.js isn't supported
// (real Safari + media-element-only environments).
const HlsModule = await import('hls.js');
const Hls = HlsModule.default;
const FetchLoader = HlsModule.FetchLoader;
if (loadGeneration !== hlsLoadGeneration || loadedSrc !== manifestUrl) {
element.removeEventListener('loadedmetadata', onLoaded);
return;
}
if (!Hls.isSupported()) {
element.src = manifestUrl;
element.load();
void constructPlyrForVideoElement();
return;
}
hlsInstance = new Hls({
enableWorker: true,
// Defer segment fetches until the user presses play. Manifest/variant
// playlist discovery still runs so duration metadata is available.
autoStartLoad: false,
// Use hls.js's fetch-based loader so we can set `credentials: 'include'`
// uniformly on the manifest GET, variant playlist GETs, and segment
// GETs. The default XhrLoader's `xhrSetup` doesn't always fire on
// every request type (some bypass it for the master playlist),
// leaving the request without cookies and the api returning 403.
// FetchLoader respects standard Fetch API credentials.
loader: FetchLoader,
fetchSetup: (context, initParams) => {
initParams.credentials = 'include';
return new Request(context.url, initParams);
}
});
const hls = hlsInstance;
const plyrFallbackTimer = setTimeout(() => {
if (loadGeneration !== hlsLoadGeneration || playerInstance) return;
void constructPlyrForVideoElement();
}, 4_000);
hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) {
handlePlaybackFailure();
if (!playerInstance) {
void constructPlyrForVideoElement();
}
}
});
hls.on(Hls.Events.MANIFEST_PARSED, () => {
clearTimeout(plyrFallbackTimer);
void initHlsPlyr(hls, loadGeneration);
});
const startBufferingOnPlay = () => {
element.removeEventListener('play', startBufferingOnPlay);
if (loadGeneration !== hlsLoadGeneration || hlsInstance !== hls) return;
hls.startLoad(-1);
};
element.addEventListener('play', startBufferingOnPlay);
hls.loadSource(manifestUrl);
hls.attachMedia(element);
}
$effect(() => {
const player = playerInstance;
const control = options.transcriptPanelControl;
const template = transcriptButtonElement;
if (!player || !control || !template) return;
transcriptPanelCleanup?.();
transcriptPanelCleanup = attachTranscriptPanelControl(player, template, control);
return () => {
transcriptPanelCleanup?.();
transcriptPanelCleanup = undefined;
};
});
onDestroy(() => {
hlsLoadGeneration += 1;
hlsPlyrGeneration = 0;
destroyHlsInstance();
if (playerInstance) {
try {
if (playerInstance.isEmbed && playerInstance.playing) {
playerInstance.pause();
}
} catch {
// No-op for player teardown safety.
}
}
teardownPlyrInstance();
});
</script>
<div
class="ui:relative ui:aspect-video ui:w-full ui:overflow-hidden ui:bg-muted"
class="ui:relative ui:aspect-video ui:w-full ui:overflow-hidden ui:rounded-md"
style="max-height: {maxHeight}; min-height: {options.minHeight}; height: {options.height};"
>
{#if isYouTube && youtubeVideoId}
@@ -166,11 +479,15 @@
></div>
{:else}
<!-- svelte-ignore a11y_media_has_caption -->
<!-- For HLS sources the $effect above assigns src (native HLS) or attaches hls.js; the bare element has no src here. -->
<!-- `preload="metadata"` keeps the poster visible until the user clicks play. -->
<!-- `crossorigin` is intentionally omitted: posters live on r2.dev (no CORS), Plyr loads its own `blank.mp4` from cdn.plyr.io (no CORS), and `<track>` works with `blob:` URLs (which the host app fetches via its API client and passes via the `tracks` prop). -->
<video
bind:this={videoElement}
{src}
src={hls ? undefined : src}
{poster}
{playsinline}
preload="metadata"
class="plyr-player ui:h-full ui:w-full ui:object-contain"
>
{#each tracks as track (track.src + track.srclang)}
@@ -185,6 +502,20 @@
</video>
{/if}
{#if playbackFailed && options.playbackErrorLabel}
<div
class="ui:absolute ui:inset-0 ui:z-20 ui:flex ui:flex-col ui:items-center ui:justify-center ui:gap-4 ui:bg-muted/95 ui:px-6 ui:text-center"
role="alert"
>
<p class="ui:text-sm ui:text-muted-foreground">{options.playbackErrorLabel}</p>
{#if options.onPlaybackReload && options.playbackReloadLabel}
<Button variant="outline" disabled={reloadInFlight} onclick={() => void handlePlaybackReloadClick()}>
{options.playbackReloadLabel}
</Button>
{/if}
</div>
{/if}
{#if options.transcriptPanelControl}
<button
bind:this={transcriptButtonElement}
@@ -13,6 +13,12 @@ export interface VideoTextTrack {
export interface VideoSource {
type: VideoType;
url: string;
/**
* When true, `url` is treated as an HLS master playlist (.m3u8). The
* player attaches hls.js (or uses native HLS on Safari) instead of
* setting the URL directly on the `<video>` element.
*/
hls?: boolean;
metadata?: {
svid?: string;
thumbnailUrl?: string;
@@ -36,6 +42,14 @@ export interface MediaPlayerOptions {
onFirstPlay?: () => void;
/** After Plyr is constructed (seek via `player.currentTime = n`). */
onPlayerReady?: (player: Plyr) => void;
/** Shown when the media element fails to load or decode. */
playbackErrorLabel?: string;
/** Label for the manual reload control shown after a playback failure. */
playbackReloadLabel?: string;
/** Fetch a fresh playback URL (or otherwise prepare a retry). Return true to retry loading. */
onPlaybackReload?: () => boolean | Promise<boolean>;
/** Fires after a new `src` finishes loading metadata (used to restore seek position). */
onSourceLoaded?: (video: HTMLVideoElement) => void;
/**
* HTML5 playback only (not YouTube/embed): adds a control-bar button after Plyr inits.
* Use for actions like reopening a transcript side panel without toggling on-video captions.
@@ -44,4 +58,12 @@ export interface MediaPlayerOptions {
label: string;
onClick: () => void;
};
/**
* For HLS sources only. Invoked before hls.js (or native HLS) loads the
* manifest. Use it to mint a short-lived signed cookie that the
* tenant-router Worker validates when serving segments in Cloud
* production. Locally / self-hosted this is typically a no-op or a
* best-effort call that fails benignly.
*/
onBeforeHlsLoad?: () => Promise<void>;
}
@@ -0,0 +1,219 @@
# Org + Course landing pages
This folder holds the entire landing-page system: the public org site (`{theme}.org`) and the per-course landing (`{theme}.course`), in 10 themes.
Both surfaces share the same nav, hero, footer, tokens, and edit context. A course landing inherits its theme from the org's `landingpage.theme` — courses don't pick their own.
## Folder map
```
packages/ui/src/custom/org-landing-page/
│
├── types.ts Public types: OrgLandingPageProps, CourseLandingPageProps,
│ CourseLandingPageLabels, OrgLandingPageTheme, …
├── theme-vars-base.ts Type + baseTokenVars for --landing-* CSS variables
├── theme-style.ts Thin registry: aggregates per-theme vars + exports themeStyle()
├── course-tokens-base.ts Type + baseCourseTokens + tokenize() helper
├── course-landing-page.tokens.ts Thin registry: aggregates per-theme courseTokens + exports
│ courseLandingTokens(theme)
├── course-landing-page.helpers.ts alignHeroCtaWithPricing, buildCourseSectionNavItems
├── fixtures.ts mockOrgLandingPageProps, mockCourseLandingPageProps
├── index.ts Public exports
│
├── edit-context.ts LandingSectionKey + setLandingPageEditContext/getLandingPageEditContext
├── editable-section.svelte Wrapper that makes a section click-to-edit when a context is set
├── landing-button.svelte Theme-aware button used by every CTA
├── landing-page-footer.svelte Shared footer, per-theme class tokens
├── landing-page-footer.tokens.ts
│
├── {theme}/ Everything for one theme (minimal, bold, classic, saas, tech,
│ │ studio, corporate, terminal, editorial, vibrant)
│ ├── index.ts re-exports { nav, hero, courseCard, org, course }
│ ├── nav.svelte reused by both org and course
│ ├── hero.svelte reused; course populates with course-shaped hero data
│ ├── course-card.svelte used by the org catalog
│ ├── org.svelte org-landing composer for this theme
│ ├── course.svelte course-landing composer for this theme
│ ├── vars.ts --landing-* CSS variables for this theme
│ └── course-tokens.ts CourseLandingTokens record for this theme
│
└── course-*.svelte Shared body components driven by `variant` + tokens:
course-section-nav, course-social-proof, course-info-blocks, course-curriculum,
course-chips, course-instructor, course-reviews, course-pricing
```
## A theme is one folder
Every artifact for a theme — composer, sub-components, CSS variables, class tokens — lives in `{theme}/`. To touch one theme you edit one directory. To delete one you `rm -rf {theme}/`. To duplicate one you copy the folder.
The central registries (`theme-style.ts`, `course-landing-page.tokens.ts`, `index.ts`) only import-and-aggregate from each `{theme}/`.
## Three-layer separation
```
┌────────────────────────────────────────┐
│ {theme}/course.svelte (orchestration)│ ← composes the theme
│ ── reuses {theme}/nav, {theme}/hero, │
│ landing-page-footer │
└──┬─────────────────────────────────────┘
│ renders, passes variant="…"
▼
┌────────────────────────────────────────┐
│ course-*.svelte (shared body) │ ← one component per section
│ • course-section-nav │ used by all 10 themes
│ • course-social-proof │
│ • course-info-blocks │
│ • course-curriculum │
│ • course-chips │
│ • course-instructor │
│ • course-reviews │
│ • course-pricing │
└──┬─────────────────────────────────────┘
│ reads courseLandingTokens(variant)
▼
┌────────────────────────────────────────┐
│ {theme}/course-tokens.ts (tokens) │ ← per-theme class strings
│ {theme}/vars.ts (vars) │ and CSS variables
└────────────────────────────────────────┘
```
The composer only orchestrates. Body components only render. Theme variation lives in tokens + CSS vars.
## Token contract
Each theme owns its `course-tokens.ts` exporting `courseTokens: CourseLandingTokens` via `tokenize({...overrides})`. The base lives in `course-tokens-base.ts`. Every body component reads `t = courseLandingTokens(variant)` and applies `{t.sectionShell}`, `{t.heading}`, `{t.curriculumModule}`, … Anything not overridden falls through to the base.
**Tokens-first rule.** Body components must not hardcode theme-varying utilities (`font-bold`, `tracking-tight`, `rounded-2xl`, `bg-card-soft`, …). Anything that differs between themes lives in tokens. Layout-only utilities that are identical everywhere (`flex`, `grid`, `gap-6`, `py-12`) can sit inline.
**Off-palette rule.** A course body section must only use surfaces that exist in the theme's org template. Don't introduce `bg-section` greys to minimal, don't paint card-soft on bold, etc.
## Data flow
The dashboard's `<CourseLandingPage>` (`apps/dashboard/src/lib/features/ui/course-landing-page/course-landing-page.svelte`) is the entry point. It:
1. Reads the org's chosen theme from `landingpage.theme`.
2. Maps `course + org` → `CourseLandingPageProps` via `buildCourseLandingPageProps()`. Section visibility (`metadata.sectionDisplay`) is honoured here — hidden sections are stripped from `info` / `reviews` / `instructor` before rendering.
3. Lazy-loads the theme bundle via `importThemeBundle(theme)` — one bundle per theme; the other nine never download. The bundle exposes `{ nav, hero, courseCard, org, course }`.
The composer wires nav/hero/footer (theme-specific files) around the shared body components, threading `variant={theme}` and the same `labels` prop into each.
## Edit context (click-to-edit)
`EditableLandingSection sectionKey="…"` wraps every editable region. When a parent route calls `setLandingPageEditContext({ selectedKey, selectKey, labelFor, iconFor })`, clicking a section in the preview calls `selectKey(key)`. The Editor sidebar reads the same key from a bound prop and opens the matching form. Without a context, the wrapper is a transparent pass-through.
Course `LandingSectionKey` values: `header`, `requirement`, `description`, `goals`, `certificate`, `curriculum`, `chips`, `instructor`, `reviews`, `pricing`.
---
## Adding a new theme
A theme is one folder. The central registries are the only files outside that folder you touch.
### 1. Declare the theme key
`types.ts`:
```ts
export type OrgLandingPageTheme =
| 'minimal'
| …
| 'newtheme';
```
### 2. Create the folder
```
newtheme/
├── index.ts
├── nav.svelte
├── hero.svelte
├── course-card.svelte
├── org.svelte
├── course.svelte
├── vars.ts
└── course-tokens.ts
```
Quickest path: `cp -R minimal/ newtheme/` and edit.
### 3. Fill in each file
- `nav.svelte`, `hero.svelte`, `course-card.svelte` — the theme's UI personality. Hero composes `{@render navigation()}` if the theme renders nav inside the hero.
- `org.svelte` — org landing composer for this theme.
- `course.svelte` — course landing composer (use any existing theme's `course.svelte` as a template; the body sequence is the same).
- `vars.ts` — exports `vars: LandingThemeVars`. Spread `...baseTokenVars` and override the differences. Use raw hex only for genuinely off-palette themes (see `terminal/vars.ts`, `editorial/vars.ts`).
- `course-tokens.ts` — exports `courseTokens: CourseLandingTokens = tokenize({ ... })`. Only override regions that differ from the base.
- `index.ts`:
```ts
export { default as nav } from './nav.svelte';
export { default as hero } from './hero.svelte';
export { default as courseCard } from './course-card.svelte';
export { default as org } from './org.svelte';
export { default as course } from './course.svelte';
```
### 4. Wire into the central registries
`theme-style.ts`:
```ts
import { vars as newthemeVars } from './newtheme/vars';
…
export const LANDING_THEME_VARS = {
…,
newtheme: newthemeVars
};
```
`course-landing-page.tokens.ts`:
```ts
import { courseTokens as newthemeTokens } from './newtheme/course-tokens';
…
const TOKENS = {
…,
newtheme: newthemeTokens
};
```
`index.ts`:
```ts
import * as newtheme from './newtheme';
export const NewthemeLandingPage = newtheme.org;
export const NewthemeCourseLanding = newtheme.course;
export const NewthemeLandingNav = newtheme.nav;
export const NewthemeLandingHero = newtheme.hero;
export const NewthemeLandingCourseCard = newtheme.courseCard;
```
### 5. Register in the dashboard utils
`apps/dashboard/src/lib/features/org/utils/landing-page.ts`:
- Add the key to `landingPageThemes`.
- Add a `case 'newtheme'` to `importThemeBundle` (this is the only lazy-load switch — `importThemeComponent`, `importThemeNavHero`, `importThemeCourseCard`, and `importCourseLandingPageTheme` are thin adapters over it).
- If your hero composes `{@render navigation()}`, add the theme key to `NAV_SNIPPET_THEMES`.
- Add an entry to `themeCourseGridClass`.
### 6. Pick body layouts
- `course-curriculum.svelte` has a `layoutByTheme` map (`accordion` for collapsible, `chapters` for permanently expanded). Add `newtheme`.
- If the info-blocks should use a different layout, do the same in `course-info-blocks.svelte`.
### 7. Storybook
In `packages/storybook/src/templates/org-landing-page/`:
- `org-landing-page.stories.svelte` — add `<NewthemeLandingPage {...mockProps} />`.
- `course-landing-page.stories.svelte` — add `<NewthemeCourseLanding {...mockProps} />`.
### 8. Audit
- Walk the new story top-to-bottom and compare against the org story for the same theme. Surfaces should match.
- `grep -nE 'font-(black|extrabold|bold|semibold|medium)|tracking-(tight|tighter|wider|widest)|rounded-(none|sm|md|lg|xl|2xl|3xl|full)' course-*.svelte` — body components should return no matches.
- `grep -nE 'bg-\[var\(--landing-(bg-section|card-soft|card)\)' newtheme/course-tokens.ts` — confirm you only used surfaces present in the org theme.
- `pnpm exec svelte-check --threshold error` from the repo root.
That's it — one folder, two registry hooks, two Storybook entries.
@@ -25,7 +25,7 @@
</script>
<Card.Root
class="ui:group ui:relative ui:overflow-hidden ui:bg-[var(--landing-bg)] ui:rounded-3xl ui:p-4 ui:flex-col ui:sm:flex-row ui:gap-6 ui:duration-300 ui:h-full ui:border-[var(--landing-border)]/50 {disableCourseLinks
class="ui:group ui:@container ui:relative ui:overflow-hidden ui:bg-[var(--landing-bg)] ui:rounded-3xl ui:p-4 ui:duration-300 ui:h-full ui:border-[var(--landing-border)]/50 {disableCourseLinks
? ''
: 'ui:hover:shadow-xl ui:hover:shadow-primary/5 ui:transition-all'}"
>
@@ -35,65 +35,67 @@
colorFrom="var(--landing-accent)"
colorTo="color-mix(in oklab, var(--landing-accent) 65%, white)"
/>
{#if course.logo}
<img
src={course.logo}
alt={course.title}
class="ui:w-full ui:sm:w-48 ui:h-48 ui:object-cover ui:rounded-2xl ui:group-hover:scale-[1.02] ui:transition-transform ui:duration-500"
/>
{/if}
<Card.Content class="ui:p-2 ui:flex ui:flex-col ui:justify-center ui:flex-1">
<Card.Title class="ui:text-2xl ui:font-bold ui:mb-2">{course.title}</Card.Title>
<Card.Description class="ui:mb-6 ui:line-clamp-2">{course.description}</Card.Description>
<div class="ui:flex ui:h-full ui:flex-col ui:@lg:flex-row ui:gap-6">
{#if course.logo}
<img
src={course.logo}
alt={course.title}
class="ui:w-full ui:@lg:w-48 ui:@lg:shrink-0 ui:h-48 ui:object-cover ui:rounded-2xl ui:group-hover:scale-[1.02] ui:transition-transform ui:duration-500"
/>
{/if}
<Card.Content class="ui:p-2 ui:flex ui:flex-col ui:justify-center ui:flex-1">
<Card.Title class="ui:text-2xl ui:font-bold ui:mb-2">{course.title}</Card.Title>
<Card.Description class="ui:mb-6 ui:line-clamp-2">{course.description}</Card.Description>
<div class="ui:flex ui:items-center ui:gap-4 ui:text-sm ui:font-semibold ui:mb-6">
{#if course.duration}
<div
class="ui:flex ui:items-center ui:gap-1.5 ui:text-[var(--landing-fg-muted)] ui:bg-[var(--landing-card-soft)] ui:px-2.5 ui:py-1 ui:rounded-md"
>
<ClockIcon class="ui:size-3.5" />
<span>{course.duration}</span>
</div>
{/if}
{#if courseTypeMeta}
{@const TypeIcon = courseTypeMeta.icon}
<div
class="ui:flex ui:items-center ui:gap-1.5 ui:text-[var(--landing-fg)] ui:bg-[var(--landing-card-soft)] ui:px-2.5 ui:py-1 ui:rounded-md"
>
<TypeIcon class={courseTypeMeta.iconClass} />
<span>{courseTypeMeta.label}</span>
</div>
{/if}
{#if primaryTag}
<div
class="ui:flex ui:items-center ui:gap-1.5 ui:text-[var(--landing-fg)] ui:bg-[var(--landing-card-soft)] ui:px-2.5 ui:py-1 ui:rounded-md"
>
<TagIcon class="ui:size-3.5 ui:text-[var(--landing-accent)]" />
{#if primaryTag.color}
<span
class="ui:inline-block ui:h-2 ui:w-2 ui:rounded-full"
style={`background-color: ${primaryTag.color}`}
aria-hidden="true"
></span>
{/if}
<span>{primaryTag.name}</span>
</div>
{/if}
</div>
<div class="ui:flex ui:items-center ui:gap-4 ui:text-sm ui:font-semibold ui:mb-6">
{#if course.duration}
<div
class="ui:flex ui:items-center ui:gap-1.5 ui:text-[var(--landing-fg-muted)] ui:bg-[var(--landing-card-soft)] ui:px-2.5 ui:py-1 ui:rounded-md"
>
<ClockIcon class="ui:size-3.5" />
<span>{course.duration}</span>
</div>
{/if}
{#if courseTypeMeta}
{@const TypeIcon = courseTypeMeta.icon}
<div
class="ui:flex ui:items-center ui:gap-1.5 ui:text-[var(--landing-fg)] ui:bg-[var(--landing-card-soft)] ui:px-2.5 ui:py-1 ui:rounded-md"
>
<TypeIcon class={courseTypeMeta.iconClass} />
<span>{courseTypeMeta.label}</span>
</div>
{/if}
{#if primaryTag}
<div
class="ui:flex ui:items-center ui:gap-1.5 ui:text-[var(--landing-fg)] ui:bg-[var(--landing-card-soft)] ui:px-2.5 ui:py-1 ui:rounded-md"
>
<TagIcon class="ui:size-3.5 ui:text-[var(--landing-accent)]" />
{#if primaryTag.color}
<span
class="ui:inline-block ui:h-2 ui:w-2 ui:rounded-full"
style={`background-color: ${primaryTag.color}`}
aria-hidden="true"
></span>
{/if}
<span>{primaryTag.name}</span>
</div>
{/if}
</div>
<div class="ui:mt-auto ui:flex ui:items-center ui:justify-between">
<span class="ui:font-black ui:text-lg">{course.price || formatCurrency(course.cost, course.currency)}</span>
<Button
href={disableCourseLinks ? undefined : course.link}
variant="outline"
class="ui:rounded-lg ui:font-bold ui:text-sm {disableCourseLinks
? 'ui:pointer-events-none'
: 'ui:hover:bg-[var(--landing-accent)] ui:hover:text-[var(--landing-accent-fg)] ui:group-hover:bg-[var(--landing-accent)] ui:group-hover:text-[var(--landing-accent-fg)] ui:transition-colors'}"
aria-disabled={disableCourseLinks}
tabindex={disableCourseLinks ? -1 : undefined}
>
{labels?.enrollLabel ?? 'Details'}
</Button>
</div>
</Card.Content>
<div class="ui:mt-auto ui:flex ui:items-center ui:justify-between">
<span class="ui:font-black ui:text-lg">{course.price || formatCurrency(course.cost, course.currency)}</span>
<Button
href={disableCourseLinks ? undefined : course.link}
variant="outline"
class="ui:rounded-lg ui:font-bold ui:text-sm {disableCourseLinks
? 'ui:pointer-events-none'
: 'ui:hover:bg-[var(--landing-accent)] ui:hover:text-[var(--landing-accent-fg)] ui:group-hover:bg-[var(--landing-accent)] ui:group-hover:text-[var(--landing-accent-fg)] ui:transition-colors'}"
aria-disabled={disableCourseLinks}
tabindex={disableCourseLinks ? -1 : undefined}
>
{labels?.enrollLabel ?? 'Details'}
</Button>
</div>
</Card.Content>
</div>
</Card.Root>
@@ -0,0 +1,42 @@
import { tokenize, type CourseLandingTokens } from '../course-tokens-base';
export const courseTokens: CourseLandingTokens = tokenize({
sectionShell: 'ui:py-14 ui:md:py-16 ui:px-4 ui:bg-[var(--landing-bg)]',
heading:
'ui:text-4xl ui:md:text-6xl ui:text-[var(--landing-fg)]' +
' ui:[font-weight:var(--landing-heading-weight)]' +
' ui:[letter-spacing:var(--landing-heading-tracking)]',
socialProofShell: 'ui:py-10 ui:px-4 ui:bg-[var(--landing-fg)] ui:text-[var(--landing-bg)]',
socialProofValue: 'ui:text-4xl ui:text-[var(--landing-bg)] ui:[font-weight:var(--landing-heading-weight)]',
socialProofLabel: 'ui:text-xs ui:text-[var(--landing-bg)]/70 ui:[letter-spacing:var(--landing-eyebrow-tracking)]',
socialProofDivider: 'ui:size-2 ui:rounded-full ui:bg-[var(--landing-accent)]',
infoBlock:
'ui:flex ui:flex-col ui:gap-3 ui:p-8 ui:border ui:border-[var(--landing-border)]/50' +
' ui:[border-radius:var(--landing-radius-card)]',
infoBlockHeading:
'ui:text-3xl ui:text-[var(--landing-fg)] ui:[font-weight:var(--landing-heading-weight)] ui:[letter-spacing:var(--landing-heading-tracking)]',
curriculumModule:
'ui:mb-3 ui:p-6 ui:border ui:border-[var(--landing-border)]/50' + ' ui:[border-radius:var(--landing-radius-card)]',
curriculumModuleTitle:
'ui:text-xl ui:md:text-2xl ui:text-[var(--landing-fg)] ui:[font-weight:var(--landing-heading-weight)] ui:[letter-spacing:var(--landing-heading-tracking)]',
curriculumModuleMeta:
'ui:text-xs ui:font-medium ui:px-3 ui:py-1 ui:bg-[var(--landing-accent)] ui:text-[var(--landing-accent-fg)]' +
' ui:[border-radius:var(--landing-radius-pill)]',
instructorShell:
'ui:flex ui:flex-col ui:md:flex-row ui:gap-6 ui:items-start ui:p-8' +
' ui:border ui:border-[var(--landing-border)]/50 ui:[border-radius:var(--landing-radius-card)]',
instructorAvatar: 'ui:size-28 ui:rounded-full ui:object-cover ui:ring-4 ui:ring-[var(--landing-accent)]',
instructorName:
'ui:text-3xl ui:text-[var(--landing-fg)] ui:[font-weight:var(--landing-heading-weight)] ui:[letter-spacing:var(--landing-heading-tracking)]',
reviewCard: 'ui:p-6 ui:border ui:border-[var(--landing-border)]/50 ui:[border-radius:var(--landing-radius-card)]',
pricingShell:
'ui:p-8 ui:bg-[var(--landing-fg)] ui:text-[var(--landing-bg)]' +
' ui:[border-radius:var(--landing-radius-card)] ui:[box-shadow:var(--landing-shadow-card)]',
pricingAmount:
'ui:text-5xl ui:md:text-6xl ui:text-[var(--landing-bg)]' +
' ui:[font-weight:var(--landing-heading-weight)] ui:[letter-spacing:var(--landing-heading-tracking)]',
pricingCurrency: 'ui:text-base ui:text-[var(--landing-bg)]/70 ui:ml-1',
pricingDiscount: 'ui:text-base ui:text-[var(--landing-bg)]/60 ui:line-through',
pricingEyebrow: 'ui:text-xs ui:text-[var(--landing-bg)]/70 ui:[letter-spacing:var(--landing-eyebrow-tracking)]',
pricingFeature: 'ui:flex ui:items-center ui:gap-2 ui:text-sm ui:text-[var(--landing-bg)]/90'
});
@@ -0,0 +1,62 @@
<script lang="ts">
import type { CourseLandingPageProps } from '../types';
import BoldNav from './nav.svelte';
import BoldHero from './hero.svelte';
import OrgLandingPageFooter from '../landing-page-footer.svelte';
import CourseSocialProof from '../course-social-proof.svelte';
import CourseInfoBlocks from '../course-info-blocks.svelte';
import CourseCurriculum from '../course-curriculum.svelte';
import CourseChips from '../course-chips.svelte';
import CourseSectionNav from '../course-section-nav.svelte';
import CourseInstructor from '../course-instructor.svelte';
import CourseReviews from '../course-reviews.svelte';
import CoursePricing from '../course-pricing.svelte';
import { themeStyle } from '../theme-style';
import { alignHeroCtaWithPricing, buildCourseSectionNavItems } from '../course-landing-page.helpers';
let {
orgName,
logoUrl,
navItems,
authAction,
hero,
socialProof,
info,
curriculum,
chips,
instructor,
reviews,
pricing,
footer,
labels
}: CourseLandingPageProps = $props();
const heroProps = $derived(alignHeroCtaWithPricing(hero, pricing));
const sectionNavItems = $derived(
buildCourseSectionNavItems({ info, curriculum, chips, instructor, reviews }, labels)
);
</script>
<div
class="ui:min-h-screen ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:font-sans"
style={themeStyle('bold')}
>
<BoldNav {orgName} {logoUrl} {navItems} {authAction} />
<main>
<BoldHero hero={heroProps} />
<CourseSectionNav variant="bold" items={sectionNavItems} {labels} />
<CourseSocialProof variant="bold" {socialProof} {labels} />
<CourseInfoBlocks variant="bold" {info} {labels} />
<CourseCurriculum variant="bold" {curriculum} {labels} />
{#if chips}
<CourseChips variant="bold" {chips} {labels} />
{/if}
<CourseInstructor variant="bold" {instructor} {labels} />
<CourseReviews variant="bold" {reviews} {labels} />
<CoursePricing variant="bold" {pricing} {labels} />
</main>
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="bold" />
</div>
@@ -6,9 +6,10 @@
interface Props {
hero: OrgLandingPageProps['hero'];
showActions?: boolean;
}
let { hero }: Props = $props();
let { hero, showActions = true }: Props = $props();
</script>
<EditableLandingSection sectionKey="hero">
@@ -16,25 +17,29 @@
<div>
<h1 class="ui:text-5xl ui:lg:text-7xl ui:font-black ui:leading-[1.1] ui:tracking-tighter">{hero.heading}</h1>
<p class="ui:text-xl ui:text-[var(--landing-fg-muted)] ui:mb-10 ui:leading-relaxed">{hero.subheading}</p>
<div class="ui:flex ui:flex-wrap ui:gap-4">
<Button
href={hero.primaryAction.href}
disabled={hero.primaryAction.disabled ?? false}
size="lg"
class="ui:rounded-xl ui:px-8 ui:py-6 ui:text-base ui:font-bold ui:shadow-lg ui:shadow-[var(--landing-accent)]/20 ui:hover:-translate-y-1 ui:transition-all"
>
{hero.primaryAction.label}
</Button>
{#if hero.secondaryAction}
<SecondaryActionButton href={hero.secondaryAction.href} label={hero.secondaryAction.label} variant="bold" />
{/if}
</div>
{#if showActions}
<div class="ui:flex ui:flex-wrap ui:gap-4">
<Button
href={hero.primaryAction.href}
disabled={hero.primaryAction.disabled ?? false}
size="lg"
class="ui:rounded-xl ui:px-8 ui:py-6 ui:text-base ui:font-bold ui:shadow-lg ui:shadow-[var(--landing-accent)]/20 ui:hover:-translate-y-1 ui:transition-all"
>
{hero.primaryAction.label}
</Button>
{#if hero.secondaryAction}
<SecondaryActionButton href={hero.secondaryAction.href} label={hero.secondaryAction.label} variant="bold" />
{/if}
</div>
{/if}
</div>
{#if hero.image}
<div
class="ui:absolute ui:inset-0 ui:bg-[var(--landing-accent)]/5 ui:rounded-3xl ui:transform ui:translate-x-4 ui:translate-y-4"
></div>
<img src={hero.image} alt="Hero" class="ui:relative ui:rounded-3xl ui:w-full ui:h-[500px] ui:object-cover" />
<div class="ui:relative">
<div
class="ui:absolute ui:inset-0 ui:bg-[var(--landing-accent)]/5 ui:rounded-3xl ui:translate-x-4 ui:translate-y-4"
></div>
<img src={hero.image} alt="Hero" class="ui:relative ui:rounded-3xl ui:w-full ui:h-[500px] ui:object-cover" />
</div>
{/if}
</section>
</EditableLandingSection>
@@ -0,0 +1,7 @@
export { default as nav } from './nav.svelte';
export { default as hero } from './hero.svelte';
export { default as courseCard } from './course-card.svelte';
export { default as org } from './org.svelte';
export { default as course } from './course.svelte';
export const coursesGridClass = 'ui:grid ui:grid-cols-1 ui:@2xl:grid-cols-2 ui:gap-6';
@@ -1,15 +1,15 @@
<script lang="ts">
import type { OrgLandingPageProps } from './types';
import OrgLandingPageEmbed from './embed.svelte';
import OrgLandingPageLinks from './links.svelte';
import OrgLandingPageCallout from './callout.svelte';
import OrgLandingPageFooter from './landing-page-footer.svelte';
import BoldNav from './bold/nav.svelte';
import BoldHero from './bold/hero.svelte';
import BoldCourseCard from './bold/course-card.svelte';
import { Button } from '../../base/button';
import { DotPattern } from '../animation/dot-pattern';
import { themeStyle } from './theme-style';
import type { OrgLandingPageProps } from '../types';
import OrgLandingPageEmbed from '../embed.svelte';
import OrgLandingPageLinks from '../links.svelte';
import OrgLandingPageCallout from '../callout.svelte';
import OrgLandingPageFooter from '../landing-page-footer.svelte';
import BoldNav from './nav.svelte';
import BoldHero from './hero.svelte';
import BoldCourseCard from './course-card.svelte';
import { Button } from '../../../base/button';
import { DotPattern } from '../../animation/dot-pattern';
import { themeStyle } from '../theme-style';
let {
orgName,
@@ -0,0 +1,10 @@
import { baseTokenVars, type LandingThemeVars } from '../theme-vars-base';
export const vars: LandingThemeVars = {
...baseTokenVars,
'--landing-heading-weight': '900',
'--landing-heading-tracking': '-0.02em',
'--landing-radius-card': '24px',
'--landing-shadow-card': '0 8px 24px -20px color-mix(in oklab, var(--landing-fg), transparent 80%)',
'--landing-divider': '1px solid var(--landing-border)'
};
@@ -0,0 +1,25 @@
import { tokenize, type CourseLandingTokens } from '../course-tokens-base';
export const courseTokens: CourseLandingTokens = tokenize({
sectionHeader: 'ui:flex ui:flex-col ui:items-center ui:text-center ui:gap-3 ui:mb-10',
heading:
'ui:text-3xl ui:md:text-4xl ui:text-[var(--landing-fg)] ui:text-center' +
' ui:[font-weight:var(--landing-heading-weight)]',
body: 'ui:text-base ui:text-[var(--landing-fg-muted)] ui:leading-relaxed ui:text-center',
headingRule: 'ui:h-px ui:w-16 ui:bg-[var(--landing-accent)] ui:mx-auto ui:mt-2',
socialProofShell: 'ui:py-8 ui:px-4 ui:border-y ui:border-[var(--landing-border)] ui:text-center',
socialProofItem: 'ui:flex ui:flex-col ui:items-center ui:gap-1 ui:text-[var(--landing-fg)]',
socialProofDivider: 'ui:w-px ui:h-10 ui:bg-[var(--landing-border)]',
infoGrid: 'ui:flex ui:flex-col ui:items-center ui:gap-16 ui:max-w-3xl ui:mx-auto',
infoBlock: 'ui:flex ui:flex-col ui:items-center ui:text-center ui:gap-3 ui:w-full',
infoBlockBody: 'ui:text-base ui:text-[var(--landing-fg-muted)] ui:leading-relaxed ui:text-center',
reviewsGrid: 'ui:grid ui:grid-cols-1 ui:md:grid-cols-3 ui:gap-6 ui:justify-items-center',
reviewCard:
'ui:p-6 ui:border ui:border-[var(--landing-border)] ui:bg-[var(--landing-card)]' +
' ui:[border-radius:var(--landing-radius-card)] ui:text-center',
instructorShell: 'ui:flex ui:flex-col ui:items-center ui:text-center ui:gap-3 ui:max-w-2xl ui:mx-auto',
instructorAvatar: 'ui:size-28 ui:rounded-full ui:object-cover ui:border-2 ui:border-[var(--landing-accent)]',
pricingShell:
'ui:p-8 ui:border ui:border-[var(--landing-border)] ui:bg-[var(--landing-card)]' +
' ui:[border-radius:var(--landing-radius-card)] ui:text-center'
});
@@ -0,0 +1,62 @@
<script lang="ts">
import type { CourseLandingPageProps } from '../types';
import ClassicNav from './nav.svelte';
import ClassicHero from './hero.svelte';
import OrgLandingPageFooter from '../landing-page-footer.svelte';
import CourseSocialProof from '../course-social-proof.svelte';
import CourseInfoBlocks from '../course-info-blocks.svelte';
import CourseCurriculum from '../course-curriculum.svelte';
import CourseChips from '../course-chips.svelte';
import CourseSectionNav from '../course-section-nav.svelte';
import CourseInstructor from '../course-instructor.svelte';
import CourseReviews from '../course-reviews.svelte';
import CoursePricing from '../course-pricing.svelte';
import { themeStyle } from '../theme-style';
import { alignHeroCtaWithPricing, buildCourseSectionNavItems } from '../course-landing-page.helpers';
let {
orgName,
logoUrl,
navItems,
authAction,
hero,
socialProof,
info,
curriculum,
chips,
instructor,
reviews,
pricing,
footer,
labels
}: CourseLandingPageProps = $props();
const heroProps = $derived(alignHeroCtaWithPricing(hero, pricing));
const sectionNavItems = $derived(
buildCourseSectionNavItems({ info, curriculum, chips, instructor, reviews }, labels)
);
</script>
<div
class="ui:min-h-screen ui:bg-[var(--landing-bg-section)] ui:text-[var(--landing-fg)] ui:font-sans"
style={themeStyle('classic')}
>
<ClassicNav {orgName} {logoUrl} {navItems} {authAction} />
<main>
<ClassicHero hero={heroProps} />
<CourseSectionNav variant="classic" items={sectionNavItems} {labels} />
<CourseSocialProof variant="classic" {socialProof} {labels} />
<CourseInfoBlocks variant="classic" {info} {labels} />
<CourseCurriculum variant="classic" {curriculum} {labels} />
{#if chips}
<CourseChips variant="classic" {chips} {labels} />
{/if}
<CourseInstructor variant="classic" {instructor} {labels} />
<CourseReviews variant="classic" {reviews} {labels} />
<CoursePricing variant="classic" {pricing} {labels} />
</main>
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="classic" />
</div>
@@ -8,9 +8,10 @@
interface Props {
hero: OrgLandingPageProps['hero'];
showActions?: boolean;
}
let { hero }: Props = $props();
let { hero, showActions = true }: Props = $props();
</script>
<EditableLandingSection sectionKey="hero">
@@ -30,23 +31,25 @@
{hero.heading}
</BlurIn>
<p class="ui:text-lg ui:sm:text-xl ui:text-[var(--landing-bg)]/80 ui:mb-10">{hero.subheading}</p>
<div class="ui:flex ui:flex-col ui:sm:flex-row ui:justify-center ui:gap-4">
<Button
href={hero.primaryAction.href}
disabled={hero.primaryAction.disabled ?? false}
size="lg"
class="ui:px-8 ui:font-semibold"
>
{hero.primaryAction.label}
</Button>
{#if hero.secondaryAction}
<SecondaryActionButton
href={hero.secondaryAction.href}
label={hero.secondaryAction.label}
variant="classic"
/>
{/if}
</div>
{#if showActions}
<div class="ui:flex ui:flex-col ui:sm:flex-row ui:justify-center ui:gap-4">
<Button
href={hero.primaryAction.href}
disabled={hero.primaryAction.disabled ?? false}
size="lg"
class="ui:px-8 ui:font-semibold"
>
{hero.primaryAction.label}
</Button>
{#if hero.secondaryAction}
<SecondaryActionButton
href={hero.secondaryAction.href}
label={hero.secondaryAction.label}
variant="classic"
/>
{/if}
</div>
{/if}
</div>
</section>
</EditableLandingSection>
@@ -0,0 +1,7 @@
export { default as nav } from './nav.svelte';
export { default as hero } from './hero.svelte';
export { default as courseCard } from './course-card.svelte';
export { default as org } from './org.svelte';
export { default as course } from './course.svelte';
export const coursesGridClass = 'ui:grid ui:grid-cols-1 ui:@xl:grid-cols-2 ui:@4xl:grid-cols-3 ui:gap-6';
@@ -1,14 +1,14 @@
<script lang="ts">
import type { OrgLandingPageProps } from './types';
import OrgLandingPageEmbed from './embed.svelte';
import OrgLandingPageLinks from './links.svelte';
import OrgLandingPageCallout from './callout.svelte';
import OrgLandingPageFooter from './landing-page-footer.svelte';
import ClassicNav from './classic/nav.svelte';
import ClassicHero from './classic/hero.svelte';
import ClassicCourseCard from './classic/course-card.svelte';
import { Button } from '../../base/button';
import { themeStyle } from './theme-style';
import type { OrgLandingPageProps } from '../types';
import OrgLandingPageEmbed from '../embed.svelte';
import OrgLandingPageLinks from '../links.svelte';
import OrgLandingPageCallout from '../callout.svelte';
import OrgLandingPageFooter from '../landing-page-footer.svelte';
import ClassicNav from './nav.svelte';
import ClassicHero from './hero.svelte';
import ClassicCourseCard from './course-card.svelte';
import { Button } from '../../../base/button';
import { themeStyle } from '../theme-style';
let {
orgName,
@@ -0,0 +1,10 @@
import { baseTokenVars, type LandingThemeVars } from '../theme-vars-base';
export const vars: LandingThemeVars = {
...baseTokenVars,
'--landing-heading-weight': '700',
'--landing-eyebrow-tracking': '0.08em',
'--landing-eyebrow-case': 'uppercase',
'--landing-radius-card': '4px',
'--landing-divider': '1px solid var(--landing-border)'
};
@@ -0,0 +1,24 @@
import { tokenize, type CourseLandingTokens } from '../course-tokens-base';
export const courseTokens: CourseLandingTokens = tokenize({
sectionShell: 'ui:py-14 ui:md:py-16 ui:px-4 ui:bg-[var(--landing-bg)]',
heading: 'ui:text-3xl ui:md:text-4xl ui:text-[var(--landing-fg)]' + ' ui:[font-weight:var(--landing-heading-weight)]',
socialProofShell: 'ui:py-8 ui:px-4 ui:border-y ui:border-[var(--landing-border)]',
socialProofLabel:
'ui:text-xs ui:text-[var(--landing-fg-muted)]' +
' ui:[letter-spacing:var(--landing-eyebrow-tracking)]' +
' ui:[text-transform:var(--landing-eyebrow-case)]',
infoGrid: 'ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:border-t ui:border-l ui:border-[var(--landing-border)]',
infoBlock: 'ui:flex ui:flex-col ui:gap-3 ui:p-8 ui:border-r ui:border-b ui:border-[var(--landing-border)]',
curriculumContainer: 'ui:border-t ui:border-[var(--landing-border)]',
curriculumModule: 'ui:border-b ui:border-[var(--landing-border)] ui:py-0',
curriculumModuleHeader:
'ui:flex ui:flex-col ui:items-start ui:gap-2 ui:py-5 ui:cursor-pointer ui:text-left ui:md:flex-row ui:md:items-center ui:md:justify-between ui:md:gap-4',
reviewsGrid: 'ui:grid ui:grid-cols-1 ui:md:grid-cols-3 ui:border-t ui:border-l ui:border-[var(--landing-border)]',
reviewCard: 'ui:p-6 ui:border-r ui:border-b ui:border-[var(--landing-border)] ui:bg-[var(--landing-bg)]',
instructorShell:
'ui:grid ui:grid-cols-1 ui:md:grid-cols-[auto_1fr] ui:gap-0 ui:border ui:border-[var(--landing-border)]',
instructorAvatarWrap:
'ui:border-b ui:md:border-b-0 ui:md:border-r ui:border-[var(--landing-border)] ui:p-6 ui:flex ui:items-center ui:justify-center',
pricingShell: 'ui:p-6 ui:border ui:border-[var(--landing-border)] ui:bg-[var(--landing-bg)]'
});
@@ -0,0 +1,64 @@
<script lang="ts">
import type { CourseLandingPageProps } from '../types';
import CorporateNav from './nav.svelte';
import CorporateHero from './hero.svelte';
import OrgLandingPageFooter from '../landing-page-footer.svelte';
import CourseSocialProof from '../course-social-proof.svelte';
import CourseInfoBlocks from '../course-info-blocks.svelte';
import CourseCurriculum from '../course-curriculum.svelte';
import CourseChips from '../course-chips.svelte';
import CourseSectionNav from '../course-section-nav.svelte';
import CourseInstructor from '../course-instructor.svelte';
import CourseReviews from '../course-reviews.svelte';
import CoursePricing from '../course-pricing.svelte';
import { themeStyle } from '../theme-style';
import { alignHeroCtaWithPricing, buildCourseSectionNavItems } from '../course-landing-page.helpers';
let {
orgName,
logoUrl,
navItems,
authAction,
hero,
socialProof,
info,
curriculum,
chips,
instructor,
reviews,
pricing,
footer,
labels
}: CourseLandingPageProps = $props();
const heroProps = $derived(alignHeroCtaWithPricing(hero, pricing));
const sectionNavItems = $derived(
buildCourseSectionNavItems({ info, curriculum, chips, instructor, reviews }, labels)
);
</script>
<div
class="ui:min-h-screen ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:font-sans"
style={themeStyle('corporate')}
>
<main>
<CorporateHero {orgName} hero={heroProps}>
{#snippet navigation()}
<CorporateNav {orgName} {logoUrl} {navItems} {authAction} />
{/snippet}
</CorporateHero>
<CourseSectionNav variant="corporate" items={sectionNavItems} {labels} />
<CourseSocialProof variant="corporate" {socialProof} {labels} />
<CourseInfoBlocks variant="corporate" {info} {labels} />
<CourseCurriculum variant="corporate" {curriculum} {labels} />
{#if chips}
<CourseChips variant="corporate" {chips} {labels} />
{/if}
<CourseInstructor variant="corporate" {instructor} {labels} />
<CourseReviews variant="corporate" {reviews} {labels} />
<CoursePricing variant="corporate" {pricing} {labels} />
</main>
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="corporate" />
</div>
@@ -9,9 +9,10 @@
orgName: OrgLandingPageProps['orgName'];
hero: OrgLandingPageProps['hero'];
navigation: Snippet;
showActions?: boolean;
}
let { orgName, hero, navigation }: Props = $props();
let { orgName, hero, navigation, showActions = true }: Props = $props();
const stats = $derived(hero.stats ?? []);
</script>
@@ -47,22 +48,24 @@
{hero.subheading}
</p>
<div class="ui:flex ui:flex-wrap ui:items-center ui:gap-3">
<Button
href={hero.primaryAction.href}
disabled={hero.primaryAction.disabled ?? false}
class="ui:rounded-none ui:px-6 ui:font-medium"
>
{hero.primaryAction.label}
</Button>
{#if hero.secondaryAction}
<SecondaryActionButton
href={hero.secondaryAction.href}
label={hero.secondaryAction.label}
variant="corporate"
/>
{/if}
</div>
{#if showActions}
<div class="ui:flex ui:flex-wrap ui:items-center ui:gap-3">
<Button
href={hero.primaryAction.href}
disabled={hero.primaryAction.disabled ?? false}
class="ui:rounded-none ui:px-6 ui:font-medium"
>
{hero.primaryAction.label}
</Button>
{#if hero.secondaryAction}
<SecondaryActionButton
href={hero.secondaryAction.href}
label={hero.secondaryAction.label}
variant="corporate"
/>
{/if}
</div>
{/if}
</div>
{#if stats.length > 0}

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